From b1dae79c9bf6bb31d59f6aa83bd2baf039841814 Mon Sep 17 00:00:00 2001 From: Heiko Klare Date: Thu, 3 Sep 2026 14:13:40 +0200 Subject: [PATCH] [Win32] Lay out the shell once per zoom change The processing of a zoom change is split into tasks, one per control, which may be performed asynchronously. The shell is laid out when the last of those tasks has been completed, which is how the layout is deferred until all controls have been adapted to the new zoom. The shell itself does not hold such a task while it propagates the zoom changed event through the widget tree. Whenever the controls are adapted synchronously, as done for child shells and for composites without a layout, the number of outstanding tasks therefore drops to zero after every single control, so the shell is laid out once per control instead of once per zoom change. Doing so is not only superfluous work, it also lays out the shell while parts of it have not been adapted yet. The propagation of the zoom changed event is thus made a task of the zoom change as well, so that the zoom change cannot be considered complete before all controls have been adapted, independently of whether they are adapted synchronously or asynchronously. Assisted-by: Claude Opus 5 --- .../org/eclipse/swt/widgets/DPITestUtil.java | 20 ++-- .../eclipse/swt/widgets/WidgetWin32Tests.java | 34 +++++++ .../org/eclipse/swt/widgets/Control.java | 96 ++++++++++++++----- .../win32/org/eclipse/swt/widgets/Shell.java | 11 ++- 4 files changed, 127 insertions(+), 34 deletions(-) diff --git a/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/DPITestUtil.java b/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/DPITestUtil.java index f105f6bec12..8e10a50a086 100644 --- a/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/DPITestUtil.java +++ b/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/DPITestUtil.java @@ -14,7 +14,6 @@ package org.eclipse.swt.widgets; import java.time.*; -import java.util.concurrent.atomic.*; import org.eclipse.swt.internal.*; import org.eclipse.swt.widgets.Control.*; @@ -30,19 +29,24 @@ public static void changeDPIZoom (Shell shell, int nativeZoom) { DPIUtil.setDeviceZoom(nativeZoom); Event event = shell.createZoomChangedEvent(nativeZoom, true); shell.sendZoomChangedEvent(event, shell); - DPIChangeExecution data = (DPIChangeExecution) event.data; - waitForDPIChange(shell, TIMEOUT_MILLIS, data.taskCount); + waitForDPIChange(shell, (DPIChangeExecution) event.data); } - private static void waitForDPIChange(Shell shell, int timeout, AtomicInteger scalingCounter) { - waitForPassCondition(shell, timeout, scalingCounter); + /** + * Performs a zoom change like the operating system does when the shell is moved + * to a monitor with a different scaling, i.e. by processing the zoom change for + * the shell instead of sending the zoom changed event to it. + */ + public static void changeDPIZoomOnMonitorChange (Shell shell, int nativeZoom) { + shell.handleMonitorSpecificDpiChange(nativeZoom, shell.getBoundsInPixels()); + waitForDPIChange(shell, (DPIChangeExecution) shell.lastDpiChangeEvent.data); } - private static void waitForPassCondition(Shell shell, int timeout, AtomicInteger scalingCounter) { - final Instant timeOut = Instant.now().plusMillis(timeout); + private static void waitForDPIChange(Shell shell, DPIChangeExecution execution) { + final Instant timeOut = Instant.now().plusMillis(TIMEOUT_MILLIS); final Display display = shell == null ? Display.getDefault() : shell.getDisplay(); - while (Instant.now().isBefore(timeOut) && scalingCounter.get() != 0) { + while (Instant.now().isBefore(timeOut) && !execution.isComplete()) { if (!display.isDisposed()) { display.readAndDispatch(); } diff --git a/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/WidgetWin32Tests.java b/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/WidgetWin32Tests.java index e7222e0371e..233bcebfd2a 100644 --- a/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/WidgetWin32Tests.java +++ b/bundles/org.eclipse.swt/Eclipse SWT Tests/win32/org/eclipse/swt/widgets/WidgetWin32Tests.java @@ -331,4 +331,38 @@ public void testCaretInStyledTextAfterZooming() { } + @Test + public void testShellIsLaidOutOnceWhenZoomChangeIsProcessedSynchronously() { + Display display = Display.getDefault(); + // A child shell processes the zoom change synchronously, i.e. all its children + // are adapted within the propagation of the zoom changed event + Shell parentShell = new Shell(display); + Shell shell = new Shell(parentShell, SWT.SHELL_TRIM); + CountingLayout layout = new CountingLayout(); + shell.setLayout(layout); + for (int i = 0; i < 3; i++) { + new Button(shell, SWT.PUSH); + } + int scaledZoom = shell.nativeZoom * 2; + + layout.layoutCount = 0; + DPITestUtil.changeDPIZoomOnMonitorChange(shell, scaledZoom); + + assertEquals("The shell must be laid out exactly once for a single zoom change", 1, layout.layoutCount); + } + + private static final class CountingLayout extends Layout { + int layoutCount; + + @Override + protected Point computeSize(Composite composite, int wHint, int hHint, boolean flushCache) { + return new Point(wHint == SWT.DEFAULT ? 100 : wHint, hHint == SWT.DEFAULT ? 100 : hHint); + } + + @Override + protected void layout(Composite composite, boolean flushCache) { + layoutCount++; + } + } + } \ No newline at end of file diff --git a/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Control.java b/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Control.java index 8aac323cd12..d9b0a9df257 100644 --- a/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Control.java +++ b/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Control.java @@ -3397,7 +3397,12 @@ public boolean setAutoscalingMode(AutoscalingMode autoscalingMode) { if (nativeZoom != newZoom) { nativeZoom = newZoom; Event zoomChangedEvent = createZoomChangedEvent(newZoom, false); - notifyListeners(SWT.ZoomChanged, zoomChangedEvent); + startZoomChangeTask(zoomChangedEvent); + try { + notifyListeners(SWT.ZoomChanged, zoomChangedEvent); + } finally { + completeZoomChangeTask(zoomChangedEvent, getShell()); + } } return true; } @@ -5141,9 +5146,7 @@ Event createZoomChangedEvent(int zoom, boolean asyncExec) { event.widget = this; event.detail = zoom; event.doit = true; - DPIChangeExecution dpiChangeExecution = new DPIChangeExecution(); - dpiChangeExecution.asyncExec = asyncExec; - event.data = dpiChangeExecution; + event.data = new DPIChangeExecution(event, asyncExec); return event; } @@ -6023,9 +6026,51 @@ LRESULT wmScrollChild (long wParam, long lParam) { return null; } +/** + * The processing of a single zoom change, which is performed in tasks that may be + * executed asynchronously. The zoom change is complete, and the shell is laid out, + * once all its tasks have been completed. + */ static class DPIChangeExecution { - AtomicInteger taskCount = new AtomicInteger(); - private boolean asyncExec = true; + private final Event event; + private final AtomicInteger taskCount = new AtomicInteger(); + private boolean asyncExec; + + private DPIChangeExecution(Event event, boolean asyncExec) { + this.event = event; + this.asyncExec = asyncExec; + } + + /** + * Registers a task of this zoom change, which must be completed with + * {@link #completeTask(Shell)}. + *

+ * Everything propagating the zoom changed event to widgets that are adapted in + * a task of their own must hold a task itself, as the zoom change would + * otherwise be considered complete as soon as the first of those widgets has + * been adapted. + *

+ */ + private void startTask() { + taskCount.incrementAndGet(); + } + + /** + * Completes a task registered with {@link #startTask()} and lays out the given + * shell if it was the last outstanding task of this zoom change. + */ + private void completeTask(Shell shell) { + if (taskCount.decrementAndGet() <= 0 && event.doit && !shell.isDisposed()) { + shell.layout(true, true); + } + } + + /** + * Returns whether all tasks of this zoom change have been completed. + */ + boolean isComplete() { + return taskCount.get() <= 0; + } private void process(Control control, Runnable operation) { boolean currentAsyncExec = asyncExec; @@ -6044,14 +6089,6 @@ private void process(Control control, Runnable operation) { // DPI change handling is finished asyncExec = currentAsyncExec; } - - private void increment() { - taskCount.incrementAndGet(); - } - - private boolean decrement() { - return taskCount.decrementAndGet() <= 0; - } } private static class DPIChangeProcessingCallback { @@ -6099,26 +6136,39 @@ static void schedule(Control control, Runnable runnable) { void sendZoomChangedEvent(Event event, Shell shell) { if (event.data instanceof DPIChangeExecution dpiExecData) { - dpiExecData.increment(); + startZoomChangeTask(event); dpiExecData.process(this, () -> { try { if (!this.isDisposed() && event.doit) { notifyListeners(SWT.ZoomChanged, event); } } finally { - if (shell.isDisposed()) { - return; - } - if (dpiExecData.decrement()) { - if (event.doit) { - shell.layout(true, true); - } - } + completeZoomChangeTask(event, shell); } }); } } +/** + * Registers a task of the zoom change the given event belongs to, see + * {@link DPIChangeExecution#startTask()}. + */ +static void startZoomChangeTask(Event event) { + if (event.data instanceof DPIChangeExecution execution) { + execution.startTask(); + } +} + +/** + * Completes a task of the zoom change the given event belongs to, see + * {@link DPIChangeExecution#completeTask(Shell)}. + */ +static void completeZoomChangeTask(Event event, Shell shell) { + if (event.data instanceof DPIChangeExecution execution) { + execution.completeTask(shell); + } +} + private boolean isAutoscalingDisabled() { return autoscalingMode != AutoscalingMode.ENABLED; } diff --git a/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Shell.java b/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Shell.java index 547063df0ce..10c54c64c41 100644 --- a/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Shell.java +++ b/bundles/org.eclipse.swt/Eclipse SWT/win32/org/eclipse/swt/widgets/Shell.java @@ -2535,7 +2535,7 @@ LRESULT WM_DPICHANGED (long wParam, long lParam) { return LRESULT.ONE; } -private void handleMonitorSpecificDpiChange(int newNativeZoom, Rectangle newBoundsInPixels) { +void handleMonitorSpecificDpiChange(int newNativeZoom, Rectangle newBoundsInPixels) { DPIUtil.setDeviceZoom (newNativeZoom); // Do not process DPI change for child shells asynchronous to avoid relayouting when // repositioning the child shell to a different monitor upon opening @@ -2545,8 +2545,13 @@ private void handleMonitorSpecificDpiChange(int newNativeZoom, Rectangle newBoun lastDpiChangeEvent.doit = false; } lastDpiChangeEvent = zoomChangedEvent; - notifyListeners(SWT.ZoomChanged, zoomChangedEvent); - this.setBoundsInPixels(newBoundsInPixels.x, newBoundsInPixels.y, newBoundsInPixels.width, newBoundsInPixels.height); + startZoomChangeTask(zoomChangedEvent); + try { + notifyListeners(SWT.ZoomChanged, zoomChangedEvent); + this.setBoundsInPixels(newBoundsInPixels.x, newBoundsInPixels.y, newBoundsInPixels.width, newBoundsInPixels.height); + } finally { + completeZoomChangeTask(zoomChangedEvent, this); + } } @Override