Skip to content

Commit a8d02f9

Browse files
fix: revalidate window tree on display change to recover stale layout
Dragging a JFrame across macOS displays with different DPI scaling factors (external 1x → built-in Retina 2x) leaves child component sizes pinned to the previous display's preferred-size values, clipping trailing glyphs of labels and buttons. The JDK fires a "graphicsConfiguration" PropertyChangeEvent on the affected Window when this happens but does not trigger any layout invalidation in response. Instrumentation confirmed the failure mode against a 35-char Noto Sans Display 14pt label on JDK 17: before fix: initial 2x: prefSize=263 size=263 ✓ drag → 1x: prefSize=256 size=263 ✓ (slack) drag → 2x: prefSize=263 size=256 ✗ TRUNCATED after fix: initial 2x: prefSize=263 size=263 ✓ drag → 1x: prefSize=256 size=256 ✓ drag → 2x: prefSize=263 size=263 ✓ `Component.getFontMetrics(font).stringWidth(text)` *does* go through a per-display FRC path on JDK 9+ and returns the correct per-display width (256 vs 263 for that string). So `getPreferredSize()` always reports the right value — the bug is purely that the layout-manager-allocated `size` never gets reassigned across the display change. == Why typing into a JTextField masks the bug == Typing calls `JTextField.revalidate()`, which propagates invalidation up through the panels via `Container.invalidate()` (which only marks the receiver invalid and propagates UP). The first ancestor whose `layoutContainer` re-runs picks up every child's new `getPreferredSize()` and reassigns their `size`, incidentally fixing all siblings of the typed-into field. Without typing, no parent layout ever re-runs after the display change, so the stale `size` persists. == The fix == In `MaterialLookAndFeel.initialize`, install a global `AWTEventListener` for `WINDOW_OPENED` that attaches a per-window `PropertyChangeListener` on the `"graphicsConfiguration"` property. On change, walk the window's component tree downward and call `Container.invalidate()` on every container, then `window.validate()` and `window.repaint()`. This forces every layout manager in the tree to re-run against the new display's per-DPI preferred sizes. Walking the tree manually is necessary because `Container.invalidate()` only marks the receiver invalid and propagates UP, never DOWN, and `Container.validate()` only recurses into children that are already invalid. So `window.invalidate(); window.validate();` alone leaves all nested Containers (the `FlowLayout`/`BorderLayout` panels that hold the truncating components) marked valid, and their `layoutContainer` is never re-run. `initialize()` also iterates `Window.getWindows()` to cover applications that switch to MaterialLookAndFeel after windows are already visible. `uninitialize()` symmetrically removes the AWTEventListener and the per-window PCLs. `SecurityException` on AWT-listener install/remove is swallowed for headless or sandboxed contexts — the L&F still works, only mixed-DPI auto-relayout is unavailable. The fix touches no UI delegates, does not reinstall the L&F, and does not call `SwingUtilities.updateComponentTreeUI` — so downstream component libraries (`JTextFieldPlaceholder`, `SwingSnackBar`, etc.) keep their per-instance state, and host-app `UIManager.put(...)` overrides are preserved. == Why the JDK doesn't do this itself == `sun.lwawt.macosx.LWWindowPeer.displayChanged()` updates the GC and fires the `"graphicsConfiguration"` PropertyChangeEvent and recurses to children via `Container.updateChildGraphicsData`, but performs no invalidate/revalidate cycle (`java/awt/Component.java:1187`, `Container.java:1185`). No JEP since JEP 263 has rewired this. The underlying defect is in the JDK; this PR routes around it from the L&F side without any JDK changes. == Relationship to existing PRs == - PR #203 ("Recompute font attributes when loading fonts"): attacked the data side of the font factory (`TextAttribute.SIZE` pinning). Was reverted on master. Orthogonal to this layout-allocation bug. - PR #207 ("Display-change watcher"): solved the same symptom by reinstalling the L&F and calling `updateComponentTreeUI(window)` on every display change. Works but breaks downstream component libraries during the `updateComponentTreeUI` cascade and resets `UIManager.put(...)` overrides via the L&F reinstall. This PR uses only the minimal cascade — `invalidateTree` + `validate` — without rebuilding UI delegates. - PR #209 (Codex's "FRACTIONALMETRICS as client property"): targeted a fractional-vs-integer FRC mismatch. Instrumentation showed that was not the actual cause; `fm.stringWidth` already adapts per-display correctly on JDK 9+. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5547a05 commit a8d02f9

1 file changed

Lines changed: 89 additions & 0 deletions

File tree

src/main/java/mdlaf/MaterialLookAndFeel.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
package mdlaf;
2323

2424
import java.awt.*;
25+
import java.awt.event.AWTEventListener;
26+
import java.awt.event.WindowEvent;
27+
import java.beans.PropertyChangeEvent;
28+
import java.beans.PropertyChangeListener;
2529
import java.lang.reflect.Method;
2630
import javax.swing.*;
2731
import javax.swing.plaf.BorderUIResource;
@@ -816,8 +820,93 @@ public UIDefaults getDefaults() {
816820
return super.getDefaults();
817821
}
818822

823+
/**
824+
* Listens for newly-shown windows and attaches a {@link #graphicsConfigurationListener} to each
825+
* one. Without this hook, dragging a window across displays of different DPI scaling factors
826+
* (e.g. external 1x → built-in Retina 2x on macOS) leaves child component sizes pinned to the
827+
* previous display's preferred-size values; trailing glyphs get clipped because the JDK fires a
828+
* {@code graphicsConfiguration} PropertyChangeEvent but does not trigger any layout invalidation
829+
* in response.
830+
*/
831+
private final AWTEventListener windowOpenedListener =
832+
new AWTEventListener() {
833+
@Override
834+
public void eventDispatched(AWTEvent event) {
835+
if (event.getID() == WindowEvent.WINDOW_OPENED && event.getSource() instanceof Window) {
836+
Window window = (Window) event.getSource();
837+
window.addPropertyChangeListener(
838+
"graphicsConfiguration", graphicsConfigurationListener);
839+
}
840+
}
841+
};
842+
843+
/**
844+
* Triggers a fresh layout pass on the affected window so child components re-query their
845+
* per-display preferred sizes and the layout manager re-assigns their {@code size}. This is the
846+
* minimal action needed to recover from the JDK's missing layout-invalidation on display change —
847+
* UI delegates are not rebuilt and L&F state is not reinstalled, so downstream component
848+
* libraries (custom UIs, host-app {@code UIManager.put} overrides, etc.) keep their state.
849+
*/
850+
private final PropertyChangeListener graphicsConfigurationListener =
851+
new PropertyChangeListener() {
852+
@Override
853+
public void propertyChange(PropertyChangeEvent evt) {
854+
if (!(evt.getSource() instanceof Window)) {
855+
return;
856+
}
857+
Window window = (Window) evt.getSource();
858+
SwingUtilities.invokeLater(
859+
() -> {
860+
// Container.invalidate() only marks one container invalid and propagates UP
861+
// (Component.java). Container.validate() only recurses into children that are
862+
// invalid. So invalidating just the Window leaves nested Containers (the
863+
// FlowLayout/BorderLayout panels that hold our components) marked valid,
864+
// and their layoutContainer is never re-run after the display change. Walk the
865+
// tree downward and invalidate every Container so validate() actually re-lays
866+
// out child sizes against the new display's per-DPI preferred-size values.
867+
invalidateTree(window);
868+
window.validate();
869+
window.repaint();
870+
});
871+
}
872+
};
873+
874+
private static void invalidateTree(Component c) {
875+
c.invalidate();
876+
if (c instanceof Container) {
877+
for (Component child : ((Container) c).getComponents()) {
878+
invalidateTree(child);
879+
}
880+
}
881+
}
882+
883+
@Override
884+
public void initialize() {
885+
super.initialize();
886+
try {
887+
Toolkit.getDefaultToolkit()
888+
.addAWTEventListener(windowOpenedListener, AWTEvent.WINDOW_EVENT_MASK);
889+
} catch (SecurityException ignored) {
890+
// Headless or restricted: skip silently. The L&F still works; only mixed-DPI auto-relayout
891+
// is unavailable.
892+
}
893+
// Cover already-open windows (relevant when the user calls UIManager.setLookAndFeel(...) on a
894+
// running application after windows are visible).
895+
for (Window window : Window.getWindows()) {
896+
window.addPropertyChangeListener("graphicsConfiguration", graphicsConfigurationListener);
897+
}
898+
}
899+
819900
@Override
820901
public void uninitialize() {
902+
try {
903+
Toolkit.getDefaultToolkit().removeAWTEventListener(windowOpenedListener);
904+
} catch (SecurityException ignored) {
905+
// Mirror the swallow in initialize().
906+
}
907+
for (Window window : Window.getWindows()) {
908+
window.removePropertyChangeListener("graphicsConfiguration", graphicsConfigurationListener);
909+
}
821910
call("uninitialize");
822911
}
823912

0 commit comments

Comments
 (0)