diff --git a/org.eclipse.draw2d.examples/src/org/eclipse/draw2d/examples/AutoScaleExample.java b/org.eclipse.draw2d.examples/src/org/eclipse/draw2d/examples/AutoScaleExample.java
new file mode 100644
index 000000000..040549bd4
--- /dev/null
+++ b/org.eclipse.draw2d.examples/src/org/eclipse/draw2d/examples/AutoScaleExample.java
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Patrick Ziegler and others.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Patrick Ziegler - initial API and implementation
+ *******************************************************************************/
+
+package org.eclipse.draw2d.examples;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+
+import org.eclipse.draw2d.ColorConstants;
+import org.eclipse.draw2d.Figure;
+import org.eclipse.draw2d.FigureCanvas;
+import org.eclipse.draw2d.FlowLayout;
+import org.eclipse.draw2d.Graphics;
+import org.eclipse.draw2d.IFigure;
+import org.eclipse.draw2d.MonitorAwareLightweightSystem;
+import org.eclipse.draw2d.geometry.Dimension;
+
+public class AutoScaleExample {
+ public static void main(String[] args) {
+ System.setProperty("swt.autoScale.updateOnRuntime", Boolean.TRUE.toString()); //$NON-NLS-1$
+ System.setProperty("draw2d.autoScale", "200"); //$NON-NLS-1$ //$NON-NLS-2$
+ Shell shell = new Shell();
+ shell.setSize(400, 400);
+ shell.setLayout(new FillLayout());
+
+ FigureCanvas canvas = new FigureCanvas(shell, SWT.NONE, new MonitorAwareLightweightSystem());
+ Figure root = new Figure();
+ root.setLayoutManager(new ListLayout());
+ root.add(createLabel(ColorConstants.red));
+ root.add(createLabel(ColorConstants.green));
+ root.add(createLabel(ColorConstants.blue));
+ root.add(createLabel(ColorConstants.yellow));
+ root.add(createLabel(ColorConstants.cyan));
+ root.add(createLabel(ColorConstants.white));
+ root.add(createLabel(ColorConstants.gray));
+ root.add(createLabel(ColorConstants.orange));
+ canvas.getViewport().setContentsTracksHeight(true);
+ canvas.getViewport().setContentsTracksWidth(true);
+ canvas.getViewport().setContents(root);
+
+ shell.open();
+
+ Display display = shell.getDisplay();
+ while (!display.isDisposed()) {
+ display.readAndDispatch();
+ }
+ }
+
+ private static IFigure createLabel(Color bg) {
+ return new Figure() {
+ @Override
+ protected void paintFigure(Graphics graphics) {
+ super.paintFigure(graphics);
+ graphics.setBackgroundColor(bg);
+ graphics.fillRectangle(getClientArea());
+ }
+
+ @Override
+ public Dimension getPreferredSize(int wHint, int hHint) {
+ return new Dimension(wHint, 100);
+ }
+ };
+ }
+
+ private static class ListLayout extends FlowLayout {
+ public ListLayout() {
+ setMajorSpacing(0);
+ }
+ }
+}
diff --git a/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/Draw2dTestSuite.java b/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/Draw2dTestSuite.java
index 40bf1af01..94784e728 100644
--- a/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/Draw2dTestSuite.java
+++ b/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/Draw2dTestSuite.java
@@ -60,7 +60,8 @@
InsetsTest.class,
DirectedGraphLayoutTest.class,
ScrollPaneTests.class,
- LabelTest.class
+ LabelTest.class,
+ HiDPITest.class
})
public class Draw2dTestSuite {
}
diff --git a/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/HiDPITest.java b/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/HiDPITest.java
new file mode 100644
index 000000000..a49453343
--- /dev/null
+++ b/org.eclipse.draw2d.tests/src/org/eclipse/draw2d/test/HiDPITest.java
@@ -0,0 +1,151 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Patrick Ziegler and others.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Patrick Ziegler - initial API and implementation
+ *******************************************************************************/
+
+package org.eclipse.draw2d.test;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Shell;
+
+import org.eclipse.draw2d.Figure;
+import org.eclipse.draw2d.FigureCanvas;
+import org.eclipse.draw2d.IFigure;
+import org.eclipse.draw2d.MonitorAwareLightweightSystem;
+import org.eclipse.draw2d.ScalableLayeredPane;
+import org.eclipse.draw2d.Viewport;
+import org.eclipse.draw2d.XYLayout;
+import org.eclipse.draw2d.geometry.Rectangle;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class HiDPITest extends BaseTestCase {
+ private Shell shell;
+ private FigureCanvas canvas;
+
+ private Figure parent;
+ private IFigure figure1;
+ private IFigure figure2;
+
+ @BeforeEach
+ public void setUp() {
+ shell = new Shell(SWT.NO_TRIM);
+ shell.setSize(400, 400);
+ shell.setLayout(new FillLayout());
+
+ figure1 = new Figure();
+ figure1.setBounds(Rectangle.SINGLETON.setBounds(50, 50, 100, 100));
+ figure2 = new Figure();
+ figure2.setBounds(Rectangle.SINGLETON.setBounds(200, 200, 50, 50));
+
+ parent = new Figure();
+ parent.setLayoutManager(new XYLayout());
+ parent.add(figure1);
+ parent.add(figure2);
+
+ canvas = new FigureCanvas(shell, new MonitorAwareLightweightSystem());
+ canvas.setContents(parent);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ shell.dispose();
+ }
+
+ @Test
+ public void test_FigureCanvas_setViewport1() {
+ canvas.setViewport(new Viewport());
+ // Only set the ScalableLayeredPane when there is something to set
+ assertNull(canvas.getContents());
+ }
+
+ @Test
+ public void test_FigureCanvas_setViewport2() {
+ IFigure figure = new Figure();
+ canvas.setViewport(new Viewport());
+ canvas.setContents(figure);
+ // Inject the ScalableLayeredPane when a new Viewport is set
+ IFigure contents = canvas.getContents();
+ assertTrue(contents instanceof ScalableLayeredPane);
+ assertEquals(contents.getChildren().size(), 1);
+ assertEquals(contents.getChildren().get(0), figure);
+ }
+
+ @Test
+ public void test_FigureCanvas_setContents() {
+ Figure figure = new Figure();
+ canvas.setContents(figure);
+ // Update contents of ScalableLayeredPane together with Viewport
+ IFigure contents = canvas.getContents();
+ assertTrue(contents instanceof ScalableLayeredPane);
+ assertEquals(contents.getChildren().size(), 1);
+ assertEquals(contents.getChildren().get(0), figure);
+ }
+
+ @Test
+ public void test_Viewport_getContents() {
+ IFigure contents = canvas.getContents();
+ assertTrue(contents instanceof ScalableLayeredPane);
+ assertEquals(contents.getChildren().size(), 1);
+ assertEquals(contents.getChildren().get(0), parent);
+ }
+
+ @Test
+ public void test_Viewport_getContents_Scale() {
+ ScalableLayeredPane contents = (ScalableLayeredPane) canvas.getContents();
+ assertEquals(contents.getScale(), 1.0);
+ canvas.notifyListeners(SWT.ZoomChanged, createZoomEvent(200));
+ assertEquals(contents.getScale(), 2.0);
+ }
+
+ @Test
+ public void test_Contents_NoZoom() {
+ Rectangle bounds1 = figure1.getBounds().getCopy();
+ Rectangle bounds2 = figure2.getBounds().getCopy();
+
+ assertEquals(50, 50, 100, 100, bounds1);
+ assertEquals(200, 200, 50, 50, bounds2);
+
+ figure1.translateToAbsolute(bounds1);
+ figure2.translateToAbsolute(bounds2);
+
+ assertEquals(50, 50, 100, 100, bounds1);
+ assertEquals(200, 200, 50, 50, bounds2);
+ }
+
+ @Test
+ public void test_Contents_200Zoom() {
+ canvas.notifyListeners(SWT.ZoomChanged, createZoomEvent(200));
+
+ Rectangle bounds1 = figure1.getBounds().getCopy();
+ Rectangle bounds2 = figure2.getBounds().getCopy();
+
+ assertEquals(50, 50, 100, 100, bounds1);
+ assertEquals(200, 200, 50, 50, bounds2);
+
+ figure1.translateToAbsolute(bounds1);
+ figure2.translateToAbsolute(bounds2);
+
+ assertEquals(100, 100, 200, 200, bounds1);
+ assertEquals(400, 400, 100, 100, bounds2);
+ }
+
+ private static Event createZoomEvent(int zoom) {
+ Event event = new Event();
+ event.type = SWT.ZoomChanged;
+ event.detail = zoom;
+ return event;
+ }
+}
diff --git a/org.eclipse.draw2d/META-INF/MANIFEST.MF b/org.eclipse.draw2d/META-INF/MANIFEST.MF
index fbc441744..403cc1ae5 100644
--- a/org.eclipse.draw2d/META-INF/MANIFEST.MF
+++ b/org.eclipse.draw2d/META-INF/MANIFEST.MF
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: %Plugin.name
Bundle-SymbolicName: org.eclipse.draw2d;singleton:=true
-Bundle-Version: 3.20.100.qualifier
+Bundle-Version: 3.21.0.qualifier
Bundle-Vendor: %Plugin.providerName
Bundle-Localization: plugin
Export-Package: org.eclipse.draw2d,
diff --git a/org.eclipse.draw2d/src/org/eclipse/draw2d/MonitorAwareLightweightSystem.java b/org.eclipse.draw2d/src/org/eclipse/draw2d/MonitorAwareLightweightSystem.java
new file mode 100644
index 000000000..d3c1d4909
--- /dev/null
+++ b/org.eclipse.draw2d/src/org/eclipse/draw2d/MonitorAwareLightweightSystem.java
@@ -0,0 +1,218 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Patrick Ziegler and others.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Patrick Ziegler - initial API and implementation
+ *******************************************************************************/
+
+package org.eclipse.draw2d;
+
+import java.beans.PropertyChangeListener;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Control;
+
+/**
+ * Subclass of the {@link LightweightSystem} to handle native HiDPI scaling.
+ *
+ *
+ * Primary purpose of this class is to take over the scaling that would normally
+ * be done by SWT. When this class is used, all figures are painted at 100% zoom
+ * and then up-scaled to match the display zoom, in an attempt to reduce the
+ * number of visual artifacts that would otherwise be introduced as a result of
+ * rounding errors that result from fractional scaling.
+ *
+ *
+ *
+ * Important: This class modifies the viewport that is set as its
+ * contents. Calling {@link Viewport#getContents()} returns a
+ * {@link ScalableLayeredPane} with the figure that is set via
+ * {@link Viewport#setContents(IFigure)} as its only child.
+ *
+ *
+ *
+ *
+ * EXPERIMENTAL. This class or interface has been added as part
+ * of a work in progress. There is no guarantee that this API will work nor that
+ * it will remain the same. Please do not use this API without consulting with
+ * the GEF-classic team.
+ *
+ *
+ * @since 3.21
+ */
+public class MonitorAwareLightweightSystem extends LightweightSystem {
+
+ /**
+ * Data set by SWT that describes the native device zoom used for this widget.
+ */
+ private static final String DATA_NATIVE_ZOOM = "NATIVE_ZOOM"; //$NON-NLS-1$
+
+ /**
+ * Data that can be set to scale this widget at 100%.
+ */
+ private static final String DATA_AUTOSCALE_DISABLED = "AUTOSCALE_DISABLED"; //$NON-NLS-1$
+
+ /**
+ * Environment variable that when enabled, takes over the native HiDPI scaling
+ * that would otherwise be done by SWT. The value needs to be a positive
+ * integer.
+ */
+ private static final String PROP_DRAW2D_AUTO_SCALE = "draw2d.autoScale"; //$NON-NLS-1$
+
+ /**
+ * Set via the {@link #PROP_DRAW2D_AUTO_SCALE} property. If not {@code null},
+ * the auto-scaling done by SWT is disabled and instead, the lightweight-system
+ * is scaled by this factor.
+ */
+ private static final Double DRAW2D_AUTO_SCALE;
+
+ static {
+ String autoScale = System.getProperty(PROP_DRAW2D_AUTO_SCALE);
+ if (autoScale != null) {
+ int zoom = parseIntUnchecked(autoScale);
+ DRAW2D_AUTO_SCALE = zoom / 100.0;
+ } else {
+ DRAW2D_AUTO_SCALE = null;
+ }
+ }
+
+ /**
+ * Convenience method to convert the integer defined by
+ * {@link #PROP_DRAW2D_AUTO_SCALE} without throwing a checked exception.
+ *
+ * @param property The property to convert.
+ * @return The integer described by the given string.
+ */
+ private static int parseIntUnchecked(String property) {
+ try {
+ return Integer.parseInt(property);
+ } catch (NumberFormatException e) {
+ throw new RuntimeException(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Returns the native device zoom for this widget.
+ *
+ * @return 1.0 at 100%, 1.25 at 125% etc.
+ */
+ private static double getNativeZoom(Control c) {
+ Object displayZoom = c.getData(DATA_NATIVE_ZOOM);
+ if (displayZoom == null) {
+ return 1.0;
+ }
+ return Double.valueOf(displayZoom.toString()) / 100.0;
+ }
+
+ /**
+ * The scalable pane that is injected between the root figure and the contents
+ * of this viewport.
+ */
+ private ScalableLayeredPane scalablePane;
+
+ /**
+ * This property-change listener is hooked to the viewport of this
+ * lightweight-system and will automatically inject the scalable pane when
+ * {@link Viewport#setContents(IFigure)} is called.
+ */
+ private final PropertyChangeListener viewportListener;
+
+ public MonitorAwareLightweightSystem() {
+ scalablePane = new ScalableLayeredPane(false);
+ scalablePane.setLayoutManager(new StackLayout());
+ viewportListener = event -> {
+ if (Viewport.PROPERTY_CONTENTS.equals(event.getPropertyName())) {
+ Viewport viewport = (Viewport) event.getSource();
+ // ignore event fired when calling viewport.setContents(scalablePane)
+ if (scalablePane == viewport.getContents()) {
+ return;
+ }
+
+ injectScalablePane(viewport);
+ }
+ };
+ }
+
+ /**
+ * Updates the scale factor of the scalable pane to the given value. This value
+ * is ignored if {@link #PROP_DRAW2D_AUTO_SCALE} has been set.
+ *
+ * @param nativeZoom The new scale factor.
+ */
+ private void setScale(double nativeZoom) {
+ if (DRAW2D_AUTO_SCALE != null) {
+ scalablePane.setScale(DRAW2D_AUTO_SCALE);
+ } else {
+ scalablePane.setScale(nativeZoom);
+ }
+ }
+
+ @Override
+ public void setControl(Canvas c) {
+ if (c == null) {
+ return;
+ }
+
+ c.setData(DATA_AUTOSCALE_DISABLED, true);
+ c.addListener(SWT.ZoomChanged, e -> setScale(e.detail / 100.0));
+ setScale(getNativeZoom(c));
+
+ super.setControl(c);
+ }
+
+ @Override
+ public void setContents(IFigure figure) {
+ if (contents instanceof Viewport vp) {
+ unhookViewport(vp);
+ }
+ super.setContents(figure);
+ if (figure instanceof Viewport vp) {
+ hookViewport(vp);
+ }
+ }
+
+ /**
+ * Remove the property-change listener from the given viewport. Called from
+ * {@link #setContents(IFigure)}.
+ *
+ * @param viewport The old viewport of this lightweight-system.
+ */
+ private void unhookViewport(Viewport viewport) {
+ viewport.removePropertyChangeListener(viewportListener);
+ }
+
+ /**
+ * Add the property-change listener to the given viewport and inject the
+ * scalable pane. Called from {@link #setContents(IFigure)}.
+ *
+ * @param viewport The new viewport of this lightweight-system.
+ */
+ private void hookViewport(Viewport viewport) {
+ injectScalablePane(viewport);
+ viewport.addPropertyChangeListener(viewportListener);
+ }
+
+ /**
+ * Injects a scalable pane between the given viewport and its contents. The old
+ * contents is moved and becomes a child of the scalable pane.
+ *
+ * @param viewport The viewport of this lightweight-system.
+ */
+ private void injectScalablePane(Viewport viewport) {
+ IFigure contents = viewport.getContents();
+
+ scalablePane.removeAll();
+
+ if (contents != null) {
+ viewport.setContents(scalablePane);
+ scalablePane.add(contents);
+ }
+ }
+}
diff --git a/org.eclipse.draw2d/src/org/eclipse/draw2d/Viewport.java b/org.eclipse.draw2d/src/org/eclipse/draw2d/Viewport.java
index f66906fd5..9ef30f6c1 100644
--- a/org.eclipse.draw2d/src/org/eclipse/draw2d/Viewport.java
+++ b/org.eclipse.draw2d/src/org/eclipse/draw2d/Viewport.java
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2000, 2010 IBM Corporation and others.
+ * Copyright (c) 2000, 2025 IBM Corporation and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
@@ -27,6 +27,13 @@ public class Viewport extends Figure implements PropertyChangeListener {
/** ID for the view location property */
public static final String PROPERTY_VIEW_LOCATION = "viewLocation"; //$NON-NLS-1$
+ /**
+ * ID for the contents property. An event with this ID is fired whenever the
+ * content of this viewport is set.
+ *
+ * @see #setContents(IFigure)
+ */
+ /* package */ static final String PROPERTY_CONTENTS = "contents"; //$NON-NLS-1$
private IFigure view;
private boolean useTranslate = false;
@@ -223,6 +230,7 @@ public void setContents(IFigure figure) {
if (view == figure) {
return;
}
+ IFigure oldView = view;
if (view != null) {
remove(view);
}
@@ -230,6 +238,7 @@ public void setContents(IFigure figure) {
if (view != null) {
add(figure);
}
+ firePropertyChange(PROPERTY_CONTENTS, oldView, view);
}
/**