This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 76f36991d8 harden getting the session display, fixes #8248 (#8271)
76f36991d8 is described below
commit 76f36991d82759a0d933571be31bcdbcb04b4277
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Sat Sep 5 22:07:37 2026 +0200
harden getting the session display, fixes #8248 (#8271)
minor improvements
---
.../apache/hop/lint/LintCanvasOverlayRefresh.java | 3 +-
.../apache/hop/lint/LintProblemsBarManager.java | 13 +-
.../org/apache/hop/lint/LintStatusFilePainter.java | 221 ++++++++++++++++-----
.../hop/lint/LintStatusBadgeCompositeTest.java | 155 +++++++++++++++
.../apache/hop/testing/gui/TestingGuiPlugin.java | 6 +-
.../hop/testing/gui/TestingGuiPluginTweakTest.java | 34 ++++
.../hop/ui/hopgui/BackgroundThreadFacadeImpl.java | 13 +-
.../ui/hopgui/BackgroundThreadFacadeImplTest.java | 27 ++-
.../org/apache/hop/core/SwtUniversalImage.java | 2 +-
.../org/apache/hop/ui/core/gui/HopNamespace.java | 5 +-
.../org/apache/hop/ui/core/widget/TreeMemory.java | 3 +-
.../org/apache/hop/ui/hopgui/SessionDisplay.java | 77 +++++++
.../hop/ui/hopgui/shared/SashFormMemory.java | 3 +-
.../apache/hop/ui/hopgui/SessionDisplayTest.java | 88 ++++++++
14 files changed, 587 insertions(+), 63 deletions(-)
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayRefresh.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayRefresh.java
index d54b1e7439..d423ffcf8e 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayRefresh.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintCanvasOverlayRefresh.java
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.Set;
import java.util.WeakHashMap;
import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
@@ -68,7 +69,7 @@ public final class LintCanvasOverlayRefresh {
display = hopGui.getShell().getDisplay();
}
if (display == null) {
- display = Display.getCurrent();
+ display = SessionDisplay.current();
}
if (display == null || display.isDisposed()) {
return;
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintProblemsBarManager.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintProblemsBarManager.java
index 640b4012be..90409d01b5 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintProblemsBarManager.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintProblemsBarManager.java
@@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap;
import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
import org.eclipse.swt.widgets.Display;
@@ -138,7 +139,7 @@ public class LintProblemsBarManager {
}
// This touches SWT widgets, so make sure it runs on the UI thread
regardless of which
// thread the caller is on (background lint threads call this too).
- if (Display.getCurrent() == null) {
+ if (SessionDisplay.current() == null) {
Display display = sessionDisplay();
if (display == null || display.isDisposed()) {
log.logDetailed(
@@ -174,7 +175,7 @@ public class LintProblemsBarManager {
"Gave up syncing the Problems tab for " + filePath + ": no editor
found in time");
return;
}
- Display display = Display.getCurrent();
+ Display display = SessionDisplay.current();
if (display == null || display.isDisposed()) {
return;
}
@@ -184,9 +185,9 @@ public class LintProblemsBarManager {
/**
* The display to get onto the UI thread with.
*
- * <p>An editor we already know about answers first: {@code
Display.getDefault()} only knows the
- * session bound to the calling thread, which is nothing at all on a thread
that was started
- * without one - and in Hop Web the wrong session's display would be worse
than none.
+ * <p>An editor we already know about answers first: the default display
only knows the session
+ * bound to the calling thread, which is nothing at all on a thread that was
started without one -
+ * and in Hop Web the wrong session's display would be worse than none.
*/
private Display sessionDisplay() {
for (HopGuiAbstractGraph graph : graphsById.values()) {
@@ -197,7 +198,7 @@ public class LintProblemsBarManager {
}
}
}
- return Display.getDefault();
+ return SessionDisplay.currentOrDefault();
}
public void refreshAllOpenEditors() {
diff --git
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
index 60043a9839..e018463d2c 100644
---
a/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
+++
b/plugins/misc/lint/src/main/java/org/apache/hop/lint/LintStatusFilePainter.java
@@ -20,15 +20,20 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
+import org.apache.hop.core.SwtUniversalImage;
import org.apache.hop.core.logging.ILogChannel;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.util.Utils;
import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import
org.apache.hop.ui.hopgui.perspective.explorer.IExplorerFilePaintListener;
import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+import org.eclipse.swt.graphics.PaletteData;
+import org.eclipse.swt.graphics.RGB;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Tree;
import org.eclipse.swt.widgets.TreeItem;
@@ -60,8 +65,8 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
/**
* The display of the GUI this painter belongs to, read when it is built on
the UI thread.
*
- * <p>{@code Display.getDefault()} answers for the session bound to the
calling thread, and the
- * results this repaints for arrive on lint threads that may have none.
+ * <p>The default display answers for the session bound to the calling
thread, and the results
+ * this repaints for arrive on lint threads that may have none.
*/
private final Display display;
@@ -77,7 +82,7 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
}
public LintStatusFilePainter() {
- this.display = Display.getCurrent() != null ? Display.getCurrent() :
Display.getDefault();
+ this.display = SessionDisplay.currentOrDefault();
updateFileStatusCache();
LintResultsManager.getInstance()
@@ -99,8 +104,11 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
* do: RWT only draws on a control, so the very first call failed and the
Explorer showed no lint
* status at all. Hop ships the three icons as SVG, and {@link GuiResource}
loads and caches them
* per session, which also settles who disposes them - not us.
+ *
+ * <p>Asked for at the size it will occupy, so the SVG is rasterized at that
size rather than a
+ * bigger bitmap being resampled down into it - resampling is what costs an
icon its edges.
*/
- private Image badgeIcon(LintStatus status) {
+ private Image badgeIcon(LintStatus status, int size) {
String location =
switch (status) {
case ERROR -> "ui/images/error.svg";
@@ -112,7 +120,7 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
return null;
}
try {
- return GuiResource.getInstance().getImage(location, BADGE_SIZE,
BADGE_SIZE);
+ return GuiResource.getInstance().getImage(location, size, size);
} catch (Exception e) {
log.logDetailed("No lint status icon available for " + status + ": " +
e.getMessage());
return null;
@@ -181,21 +189,17 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
}
}
- Image badge = badgeIcon(status);
- if (badge == null || badge.isDisposed()) {
- return;
- }
switch (status) {
case ERROR:
- addOverlayIcon(treeItem, badge, status);
+ addOverlayIcon(treeItem, status);
addLintTooltip(treeItem, name, "Linter errors");
break;
case WARNING:
- addOverlayIcon(treeItem, badge, status);
+ addOverlayIcon(treeItem, status);
addLintTooltip(treeItem, name, "Linter warnings");
break;
case CLEAN:
- addOverlayIcon(treeItem, badge, status);
+ addOverlayIcon(treeItem, status);
addLintTooltip(treeItem, name, "No linter issues");
break;
default:
@@ -263,7 +267,7 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
return LintStatus.UNKNOWN;
}
- private void addOverlayIcon(TreeItem treeItem, Image lintIcon, LintStatus
status) {
+ private void addOverlayIcon(TreeItem treeItem, LintStatus status) {
try {
// Already showing this exact status for this item -> nothing to do
(avoids re-compositing
// on every paint, which previously leaked a new Image each time and
crashed the GUI).
@@ -282,22 +286,31 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
}
}
- if (base == null || base.isDisposed()) {
+ boolean noBase = base == null || base.isDisposed();
+ org.eclipse.swt.graphics.Rectangle bounds = noBase ? null :
base.getBounds();
+ // Rasterize the badge at the size it will occupy on this icon, so
nothing is resampled.
+ Image lintIcon =
+ badgeIcon(
+ status, bounds == null ? BADGE_SIZE : badgeSizeFor(bounds.width,
bounds.height));
+ if (lintIcon == null || lintIcon.isDisposed()) {
+ return;
+ }
+
+ if (bounds == null) {
treeItem.setImage(lintIcon);
treeItem.setData(APPLIED_STATUS_KEY, status);
return;
}
- org.eclipse.swt.graphics.Rectangle bounds = base.getBounds();
if (bounds.width > 100 || bounds.height > 100) {
treeItem.setImage(lintIcon);
treeItem.setData(APPLIED_STATUS_KEY, status);
return;
}
- // No composite (Hop Web cannot draw one): leave the file's own icon
alone. The item's
- // colour already says what the status is, and replacing the icon with a
bare badge would
- // cost more than it tells.
+ // Without a composite, leave the file's own icon alone. The item's
colour already says
+ // what the status is, and replacing the icon with a bare badge would
cost more than it
+ // tells.
Image compositeIcon = getOrCreateComposite(base, lintIcon);
if (compositeIcon != null) {
treeItem.setImage(compositeIcon);
@@ -305,7 +318,11 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
treeItem.setData(APPLIED_STATUS_KEY, status);
} catch (Exception e) {
log.logError("Error creating overlay icon: " + e.getMessage(), e);
- treeItem.setImage(lintIcon);
+ // The bare badge still says what the status is, which beats leaving the
item unmarked.
+ Image fallback = badgeIcon(status, BADGE_SIZE);
+ if (fallback != null && !fallback.isDisposed()) {
+ treeItem.setImage(fallback);
+ }
}
}
@@ -323,45 +340,157 @@ public class LintStatusFilePainter implements
IExplorerFilePaintListener {
return composite;
}
+ /**
+ * The file's own icon with the lint badge in its bottom right corner.
+ *
+ * <p>Composited pixel by pixel rather than with a {@code GC}. RWT resolves
both the device and
+ * the drawing delegate of a {@code GC} from its drawable and understands
only a Control or a
+ * Device, so a {@code GC} on an Image is left with neither: the drawing
fails with a
+ * NullPointerException, and disposing it then fails with "A factory-created
resource cannot be
+ * disposed", which is the exception that reaches the log. Working on the
{@link ImageData} of
+ * both icons needs no drawing surface at all, so Hop Web gets the same
badges as the desktop.
+ */
private Image createCompositeIcon(Image originalIcon, Image lintIcon) {
try {
if (display == null || display.isDisposed()) {
return null;
}
-
- org.eclipse.swt.graphics.Rectangle originalBounds =
originalIcon.getBounds();
- org.eclipse.swt.graphics.Rectangle lintBounds = lintIcon.getBounds();
-
- Image composite = new Image(display, originalBounds.width,
originalBounds.height);
- org.eclipse.swt.graphics.GC gc = new
org.eclipse.swt.graphics.GC(composite);
- try {
- gc.setBackground(display.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND));
- gc.fillRectangle(0, 0, originalBounds.width, originalBounds.height);
- gc.drawImage(originalIcon, 0, 0);
-
- int badgeSize = Math.min(10, Math.min(originalBounds.width / 2,
originalBounds.height / 2));
- int lintX = originalBounds.width - badgeSize - 1;
- int lintY = originalBounds.height - badgeSize - 1;
- gc.drawImage(
- lintIcon,
- 0,
- 0,
- lintBounds.width,
- lintBounds.height,
- lintX,
- lintY,
- badgeSize,
- badgeSize);
- return composite;
- } finally {
- gc.dispose();
+ ImageData baseData = SwtUniversalImage.getImageDataAtZoom(originalIcon,
100);
+ if (baseData == null) {
+ return null;
}
+ int badgeSize = badgeSizeFor(baseData.width, baseData.height);
+ if (badgeSize <= 0) {
+ return null;
+ }
+ // Composited again for every zoom the platform asks for, out of what
both icons themselves
+ // have at that zoom. Handing over the 100% pixels alone and letting SWT
raster-scale them up
+ // is what leaves icons blurry on a HiDPI screen - the very thing
createDpiAwareImage exists
+ // to avoid.
+ return SwtUniversalImage.createDpiAwareImage(
+ display,
+ zoom ->
+ withBadge(
+ SwtUniversalImage.getImageDataAtZoom(originalIcon, zoom),
+ SwtUniversalImage.getImageDataAtZoom(lintIcon, zoom),
+ SwtUniversalImage.pixelSize(badgeSize, zoom),
+ SwtUniversalImage.pixelSize(1, zoom)));
} catch (Exception e) {
log.logError("Error creating composite icon: " + e.getMessage(), e);
return null;
}
}
+ /** A corner mark on an icon this size: half its width at most, and never
more than 10px. */
+ private static int badgeSizeFor(int width, int height) {
+ return Math.min(10, Math.min(width / 2, height / 2));
+ }
+
+ /**
+ * The base icon with the badge scaled into its bottom right corner, blended
over whatever the
+ * base has there rather than punched through it, so a badge with soft edges
does not leave a hard
+ * outline. What the base leaves transparent stays transparent: the tree
paints its own background
+ * behind the icon.
+ *
+ * <p>Sizes are in the pixels of the icons handed in, so that the same badge
lands in the same
+ * place whichever zoom these pixels came from.
+ */
+ static ImageData withBadge(ImageData baseData, ImageData badgeData, int
badgeSize, int margin) {
+ ImageData composite = withPerPixelAlpha(baseData);
+ ImageData scaled = badgeData.scaledTo(badgeSize, badgeSize);
+ ImageData scaledMask = transparencyMask(scaled);
+ int offsetX = composite.width - badgeSize - margin;
+ int offsetY = composite.height - badgeSize - margin;
+
+ for (int y = 0; y < badgeSize; y++) {
+ int targetY = offsetY + y;
+ if (targetY < 0 || targetY >= composite.height) {
+ continue;
+ }
+ for (int x = 0; x < badgeSize; x++) {
+ int targetX = offsetX + x;
+ if (targetX < 0 || targetX >= composite.width) {
+ continue;
+ }
+ int overAlpha = alphaAt(scaled, scaledMask, x, y);
+ if (overAlpha == 0) {
+ continue;
+ }
+ RGB over = scaled.palette.getRGB(scaled.getPixel(x, y));
+ if (overAlpha == 255) {
+ composite.setPixel(targetX, targetY,
composite.palette.getPixel(over));
+ composite.setAlpha(targetX, targetY, 255);
+ continue;
+ }
+ RGB under = composite.palette.getRGB(composite.getPixel(targetX,
targetY));
+ int underAlpha = composite.getAlpha(targetX, targetY);
+ int outAlpha = overAlpha + underAlpha * (255 - overAlpha) / 255;
+ composite.setPixel(
+ targetX,
+ targetY,
+ composite.palette.getPixel(blend(over, under, overAlpha,
underAlpha)));
+ composite.setAlpha(targetX, targetY, outAlpha);
+ }
+ }
+ return composite;
+ }
+
+ /**
+ * The same picture in the one shape we can composite into: direct colour
with an alpha value per
+ * pixel. An icon can express its transparency in any of several ways and
only this one can be
+ * written back to, so the base is read through {@link #alphaAt} and
rewritten as this.
+ */
+ private static ImageData withPerPixelAlpha(ImageData source) {
+ ImageData copy =
+ new ImageData(source.width, source.height, 24, new
PaletteData(0xFF0000, 0xFF00, 0xFF));
+ copy.alphaData = new byte[source.width * source.height];
+ ImageData mask = transparencyMask(source);
+ for (int y = 0; y < source.height; y++) {
+ for (int x = 0; x < source.width; x++) {
+ RGB rgb = source.palette.getRGB(source.getPixel(x, y));
+ copy.setPixel(x, y, copy.palette.getPixel(rgb));
+ copy.setAlpha(x, y, alphaAt(source, mask, x, y));
+ }
+ }
+ return copy;
+ }
+
+ /** The 1-bit mask of an icon that carries one (ICO, BMP), or null - read
once, not per pixel. */
+ private static ImageData transparencyMask(ImageData data) {
+ return data.maskData == null ? null : data.getTransparencyMask();
+ }
+
+ /** How opaque one pixel is, whichever of the four ways the icon says so. */
+ private static int alphaAt(ImageData data, ImageData mask, int x, int y) {
+ if (mask != null && mask.getPixel(x, y) == 0) {
+ return 0;
+ }
+ if (data.transparentPixel != -1 && data.getPixel(x, y) ==
data.transparentPixel) {
+ return 0;
+ }
+ if (data.alphaData != null) {
+ return data.getAlpha(x, y);
+ }
+ return data.alpha == -1 ? 255 : data.alpha;
+ }
+
+ /** Source-over: the colour left when {@code over} is laid on {@code under}.
*/
+ private static RGB blend(RGB over, RGB under, int overAlpha, int underAlpha)
{
+ return new RGB(
+ channel(over.red, under.red, overAlpha, underAlpha),
+ channel(over.green, under.green, overAlpha, underAlpha),
+ channel(over.blue, under.blue, overAlpha, underAlpha));
+ }
+
+ private static int channel(int over, int under, int overAlpha, int
underAlpha) {
+ int outAlpha = overAlpha + underAlpha * (255 - overAlpha) / 255;
+ if (outAlpha == 0) {
+ return 0;
+ }
+ int weighted = over * overAlpha * 255 + under * underAlpha * (255 -
overAlpha);
+ return Math.min(255, weighted / (outAlpha * 255));
+ }
+
private void addLintTooltip(TreeItem treeItem, String fileName, String
lintStatus) {
try {
treeItem.setData("lintTooltip", fileName + " - " + lintStatus);
diff --git
a/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintStatusBadgeCompositeTest.java
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintStatusBadgeCompositeTest.java
new file mode 100644
index 0000000000..d25fb1290a
--- /dev/null
+++
b/plugins/misc/lint/src/test/java/org/apache/hop/lint/LintStatusBadgeCompositeTest.java
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.lint;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.eclipse.swt.graphics.ImageData;
+import org.eclipse.swt.graphics.PaletteData;
+import org.eclipse.swt.graphics.RGB;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The Explorer badges a file's own icon with its lint status. RWT cannot draw
onto an Image - a
+ * {@code GC} built on one has neither a device nor a drawing delegate - so
the badge is composited
+ * from the {@link ImageData} of both icons, which needs no drawing surface
and works the same on
+ * the desktop and in Hop Web.
+ */
+class LintStatusBadgeCompositeTest {
+
+ private static final RGB BASE_COLOR = new RGB(10, 20, 30);
+ private static final RGB BADGE_COLOR = new RGB(200, 100, 50);
+
+ /** A 16x16 icon of one colour, opaque, with an alpha channel - what an SVG
icon loads as. */
+ private static ImageData icon(int size, RGB color, int alpha) {
+ ImageData data = new ImageData(size, size, 24, new PaletteData(0xFF0000,
0xFF00, 0xFF));
+ data.alphaData = new byte[size * size];
+ for (int y = 0; y < size; y++) {
+ for (int x = 0; x < size; x++) {
+ data.setPixel(x, y, data.palette.getPixel(color));
+ data.setAlpha(x, y, alpha);
+ }
+ }
+ return data;
+ }
+
+ private static RGB colorAt(ImageData data, int x, int y) {
+ return data.palette.getRGB(data.getPixel(x, y));
+ }
+
+ @Test
+ @DisplayName("the badge lands in the bottom right corner and leaves the rest
of the icon alone")
+ void badgeIsCompositedIntoTheCorner() {
+ ImageData composite =
+ LintStatusFilePainter.withBadge(icon(16, BASE_COLOR, 255), icon(8,
BADGE_COLOR, 255), 8, 1);
+
+ assertEquals(16, composite.width);
+ assertEquals(16, composite.height);
+ // One pixel of margin is kept, so the very last column and row stay the
base icon.
+ assertEquals(BADGE_COLOR, colorAt(composite, 14, 14));
+ assertEquals(BASE_COLOR, colorAt(composite, 15, 15));
+ assertEquals(BASE_COLOR, colorAt(composite, 0, 0));
+ assertEquals(BASE_COLOR, colorAt(composite, 6, 6));
+ }
+
+ @Test
+ @DisplayName("what the badge leaves transparent shows the icon underneath")
+ void fullyTransparentBadgePixelsChangeNothing() {
+ ImageData composite =
+ LintStatusFilePainter.withBadge(icon(16, BASE_COLOR, 255), icon(8,
BADGE_COLOR, 0), 8, 1);
+
+ assertEquals(BASE_COLOR, colorAt(composite, 14, 14));
+ assertEquals(255, composite.getAlpha(14, 14));
+ }
+
+ @Test
+ @DisplayName("what the icon leaves transparent stays transparent")
+ void theIconsOwnTransparencySurvives() {
+ ImageData composite =
+ LintStatusFilePainter.withBadge(icon(16, BASE_COLOR, 0), icon(8,
BADGE_COLOR, 255), 8, 1);
+
+ assertEquals(0, composite.getAlpha(0, 0));
+ // Where the badge is opaque it wins outright, transparent base or not.
+ assertEquals(255, composite.getAlpha(14, 14));
+ assertEquals(BADGE_COLOR, colorAt(composite, 14, 14));
+ }
+
+ /** Palette icons say "transparent" with a pixel value rather than an alpha
channel. */
+ @Test
+ @DisplayName("a palette icon's transparent pixel is read as transparent, not
as its colour")
+ void transparentPixelIsHonoured() {
+ PaletteData palette = new PaletteData(BASE_COLOR, BADGE_COLOR);
+ ImageData base = new ImageData(16, 16, 8, palette);
+ base.transparentPixel = 1;
+ for (int y = 0; y < 16; y++) {
+ for (int x = 0; x < 16; x++) {
+ base.setPixel(x, y, x == 0 && y == 0 ? 1 : 0);
+ }
+ }
+
+ ImageData composite = LintStatusFilePainter.withBadge(base, icon(8,
BADGE_COLOR, 255), 8, 1);
+
+ assertEquals(0, composite.getAlpha(0, 0));
+ assertEquals(255, composite.getAlpha(1, 1));
+ assertEquals(BASE_COLOR, colorAt(composite, 1, 1));
+ }
+
+ /**
+ * The platform asks for the composite again at each zoom it paints at, so
the same badge is built
+ * from bigger pixels rather than by scaling up the 100% one - that is what
keeps a HiDPI icon
+ * sharp. Everything scales together, the margin included, so the badge
lands in the same place on
+ * the icon at every zoom.
+ */
+ @Test
+ @DisplayName("at 200% everything is twice the size and the badge sits in the
same place")
+ void geometryScalesWithTheZoom() {
+ ImageData at100 =
+ LintStatusFilePainter.withBadge(icon(16, BASE_COLOR, 255), icon(8,
BADGE_COLOR, 255), 8, 1);
+ ImageData at200 =
+ LintStatusFilePainter.withBadge(
+ icon(32, BASE_COLOR, 255), icon(16, BADGE_COLOR, 255), 16, 2);
+
+ assertEquals(32, at200.width);
+ // The badge corner of the 100% icon, at twice the scale, is still the
badge corner.
+ assertEquals(BADGE_COLOR, colorAt(at100, 7, 7));
+ assertEquals(BADGE_COLOR, colorAt(at200, 14, 14));
+ // And so is the last pixel before the margin.
+ assertEquals(BADGE_COLOR, colorAt(at100, 14, 14));
+ assertEquals(BADGE_COLOR, colorAt(at200, 29, 29));
+ // The margin itself stays the base icon at both zooms.
+ assertEquals(BASE_COLOR, colorAt(at100, 15, 15));
+ assertEquals(BASE_COLOR, colorAt(at200, 30, 30));
+ assertEquals(BASE_COLOR, colorAt(at200, 31, 31));
+ }
+
+ @Test
+ @DisplayName("a half transparent badge blends with the icon rather than
replacing it")
+ void partialAlphaBlends() {
+ ImageData composite =
+ LintStatusFilePainter.withBadge(
+ icon(16, new RGB(0, 0, 0), 255), icon(8, new RGB(255, 255, 255),
128), 8, 1);
+
+ // Half of white over black, opaque either way.
+ RGB blended = colorAt(composite, 14, 14);
+ assertEquals(128, blended.red);
+ assertEquals(128, blended.green);
+ assertEquals(128, blended.blue);
+ assertEquals(255, composite.getAlpha(14, 14));
+ }
+}
diff --git
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
index 61437fb05d..c8d8a3b534 100644
---
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
+++
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
@@ -78,6 +78,7 @@ import org.apache.hop.ui.core.widget.ColumnInfo;
import org.apache.hop.ui.core.widget.ComboFilterPopup;
import org.apache.hop.ui.core.widget.TableView;
import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
import org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineContext;
@@ -92,7 +93,6 @@ import org.eclipse.swt.SWT;
import org.eclipse.swt.SWTException;
import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Control;
-import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.TableItem;
@@ -1402,7 +1402,7 @@ public class TestingGuiPlugin {
*/
public static HopGuiPipelineGraph getPipelineGraph(PipelineMeta
pipelineMeta) {
// Tab / graph lookup may touch SWT widgets; only safe on the UI thread
(issue #7896).
- if (Display.getCurrent() == null) {
+ if (SessionDisplay.current() == null) {
return null;
}
try {
@@ -1441,7 +1441,7 @@ public class TestingGuiPlugin {
return null;
}
// Same rule as getPipelineGraph: never access HopGui/SWT from a worker
thread (issue #7896).
- if (Display.getCurrent() == null) {
+ if (SessionDisplay.current() == null) {
return null;
}
Map<String, Object> stateMap = getStateMap(pipelineMeta);
diff --git
a/plugins/misc/testing/src/test/java/org/apache/hop/testing/gui/TestingGuiPluginTweakTest.java
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/gui/TestingGuiPluginTweakTest.java
index 64da9ee26d..697e06a56f 100644
---
a/plugins/misc/testing/src/test/java/org/apache/hop/testing/gui/TestingGuiPluginTweakTest.java
+++
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/gui/TestingGuiPluginTweakTest.java
@@ -21,14 +21,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mockStatic;
import java.util.List;
+import org.apache.hop.core.Const;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.transform.TransformMeta;
import org.apache.hop.testing.PipelineTweak;
import org.apache.hop.testing.PipelineUnitTest;
import org.apache.hop.testing.PipelineUnitTestTweak;
+import org.eclipse.swt.widgets.Display;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
/**
* Unit tests for multi-transform unit-test tweak application (issue #2742).
Covers pure helpers in
@@ -87,6 +91,36 @@ class TestingGuiPluginTweakTest {
assertNull(TestingGuiPlugin.getStateMap(new PipelineMeta()));
}
+ /**
+ * In Hop Web the guard itself used to be the failure (issue #8248):
background work carries a RAP
+ * session over to the thread that runs it, and once that session has been
destroyed RWT throws
+ * from {@code Display.getCurrent()} rather than answering "no display". The
exception escaped
+ * into GetFields, which logged "Error calling extension point
'GetFieldsExtension'" for every
+ * transform. A thread whose session is gone is a thread with no unit test.
+ */
+ @Test
+ void getCurrentUnitTestReturnsNullWhenTheSessionIsGone() {
+ String runtime = System.getProperty(Const.HOP_PLATFORM_RUNTIME);
+ System.setProperty(Const.HOP_PLATFORM_RUNTIME, "GUI");
+ try (MockedStatic<Display> display = mockStatic(Display.class)) {
+ display
+ .when(Display::getCurrent)
+ .thenThrow(
+ new NullPointerException(
+ "Cannot invoke
\"org.eclipse.rap.rwt.service.UISession.getAttribute(String)\""
+ + " because \"uiSession\" is null"));
+
+ assertNull(TestingGuiPlugin.getCurrentUnitTest(new PipelineMeta()));
+ assertNull(TestingGuiPlugin.getStateMap(new PipelineMeta()));
+ } finally {
+ if (runtime == null) {
+ System.clearProperty(Const.HOP_PLATFORM_RUNTIME);
+ } else {
+ System.setProperty(Const.HOP_PLATFORM_RUNTIME, runtime);
+ }
+ }
+ }
+
@Test
void applyTweakEnableAddsBypass() {
PipelineUnitTest unitTest = new PipelineUnitTest();
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImpl.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImpl.java
index 9e4efd74f8..aa8c96ffb9 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImpl.java
@@ -35,7 +35,18 @@ public class BackgroundThreadFacadeImpl extends
BackgroundThreadFacade {
}
// exec() binds the session to whatever thread calls it, which is the
point here: the work
// itself runs exactly as before, only now with a session to ask.
- return () -> uiSession.exec(runnable);
+ return () -> {
+ if (!uiSession.isBound()) {
+ // The session ended between scheduling this work and running it - the
browser closed, the
+ // user logged out, the session timed out. Binding it anyway leaves
the thread with a
+ // context that resolves to nothing, which is worse than no context at
all: RWT reads it
+ // back unchecked and even asking whether this thread has a display
then fails with a
+ // NullPointerException (issue #8248). The work itself never needed
the session.
+ runnable.run();
+ return;
+ }
+ uiSession.exec(runnable);
+ };
}
/** The session serving this thread, or null when there is none to carry
over. */
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImplTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImplTest.java
index 853f0b75c8..4552f50094 100644
---
a/rap/src/test/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImplTest.java
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/BackgroundThreadFacadeImplTest.java
@@ -20,8 +20,10 @@ package org.apache.hop.ui.hopgui;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
import org.eclipse.rap.rwt.RWT;
import org.eclipse.rap.rwt.service.UISession;
@@ -40,6 +42,7 @@ class BackgroundThreadFacadeImplTest {
@DisplayName("work started from a session runs with that session bound")
void carriesTheSessionOverToTheBackgroundThread() {
UISession uiSession = mock(UISession.class);
+ when(uiSession.isBound()).thenReturn(true);
Runnable work = mock(Runnable.class);
try (MockedStatic<RWT> rwt = mockStatic(RWT.class)) {
@@ -48,7 +51,7 @@ class BackgroundThreadFacadeImplTest {
Runnable bound = new BackgroundThreadFacadeImpl().bindInternal(work);
// The session is read here, on the thread that starts the work; the
work itself waits.
- verifyNoInteractions(uiSession, work);
+ verifyNoInteractions(work);
bound.run();
@@ -57,6 +60,28 @@ class BackgroundThreadFacadeImplTest {
}
}
+ /**
+ * A session that ended while the work was queued must not be made current:
RWT hands out a
+ * context that resolves to no session, and reading it back fails with a
NullPointerException as
+ * far away as {@code Display.getCurrent()} (issue #8248). The work still
runs.
+ */
+ @Test
+ @DisplayName("a session that ended in the meantime is not carried over")
+ void runsWithoutASessionThatDiedBeforeTheWorkStarted() {
+ UISession uiSession = mock(UISession.class);
+ when(uiSession.isBound()).thenReturn(false);
+ Runnable work = mock(Runnable.class);
+
+ try (MockedStatic<RWT> rwt = mockStatic(RWT.class)) {
+ rwt.when(() -> RWT.getUISession()).thenReturn(uiSession);
+
+ new BackgroundThreadFacadeImpl().bindInternal(work).run();
+
+ verify(work).run();
+ verify(uiSession, never()).exec(work);
+ }
+ }
+
@Test
@DisplayName("without a session there is nothing to carry over")
void leavesTheWorkAloneOutsideASession() {
diff --git a/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
b/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
index e2804fe4fe..750ee54344 100644
--- a/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
+++ b/ui/src/main/java/org/apache/hop/core/SwtUniversalImage.java
@@ -133,7 +133,7 @@ public abstract class SwtUniversalImage {
* Pixel size of a logical extent at an SWT zoom percentage. Must be linear
({@code 200} → {@code
* 2 * 100}) to satisfy the desktop ImageDataProvider contract.
*/
- static int pixelSize(int logical, int zoom) {
+ public static int pixelSize(int logical, int zoom) {
return Math.max(1, logical * zoom / 100);
}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
b/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
index f3d71258fe..501b616e16 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/HopNamespace.java
@@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap;
import org.apache.hop.core.Const;
import org.apache.hop.core.exception.HopRuntimeException;
import org.apache.hop.core.util.Utils;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.eclipse.swt.SWT;
import org.eclipse.swt.widgets.Display;
@@ -97,7 +98,7 @@ public class HopNamespace {
/** The namespace of the session on this thread, or null. Only call with a
user interface. */
private static String namespaceOfCurrentDisplay() {
- Display display = Display.getCurrent();
+ Display display = SessionDisplay.current();
if (display == null || display.isDisposed()) {
return null;
}
@@ -106,7 +107,7 @@ public class HopNamespace {
/** Remember the namespace for the session on this thread. Only call with a
user interface. */
private static void rememberForCurrentDisplay(String namespace) {
- Display display = Display.getCurrent();
+ Display display = SessionDisplay.current();
if (display == null || display.isDisposed()) {
return;
}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
index d762498a40..8fe7bd04e9 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TreeMemory.java
@@ -21,6 +21,7 @@ import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.TreeEvent;
import org.eclipse.swt.events.TreeListener;
@@ -40,7 +41,7 @@ public class TreeMemory {
private Map<TreeMemoryEntry, Boolean> map;
public static final TreeMemory getInstance() {
- Display display = Display.getCurrent();
+ Display display = SessionDisplay.current();
if (display != null && !display.isDisposed()) {
return BY_DISPLAY.computeIfAbsent(
display,
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/SessionDisplay.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/SessionDisplay.java
new file mode 100644
index 0000000000..e2f0deb912
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/SessionDisplay.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import org.eclipse.swt.widgets.Display;
+
+/**
+ * Answers the question "does this thread have a display to work with?"
without ever throwing.
+ *
+ * <p>{@code Display.getCurrent()} is the usual way to ask, and on the desktop
it is a plain
+ * thread-to-display lookup that cannot fail. In Hop Web it is not: RWT
resolves the display through
+ * the RAP session bound to the calling thread, so asking the question reaches
into that session.
+ *
+ * <p>{@link BackgroundThreadFacade} deliberately binds a session to the
threads that run background
+ * work, so those threads do have one to reach into. A session that has since
been destroyed - the
+ * browser closed, the user logged out, the session timed out - is still bound
to the thread but no
+ * longer resolves, and RWT dereferences it unchecked: the guard meant to keep
background threads
+ * away from the UI is where the {@code NullPointerException} comes from
instead (issue #8248,
+ * following #7896 / #7897).
+ *
+ * <p>A thread whose session died is a thread with no display, which is what
these methods answer.
+ */
+public class SessionDisplay {
+
+ private SessionDisplay() {
+ // Utility class
+ }
+
+ /**
+ * The display this thread is the user interface thread for.
+ *
+ * @return the display, or null when this thread has none - including when
the session that would
+ * have provided it is gone
+ */
+ public static Display current() {
+ try {
+ return Display.getCurrent();
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /**
+ * The display of this thread, falling back to the default display.
+ *
+ * <p>For callers that want a display to schedule work on rather than an
answer about the calling
+ * thread. The fallback resolves through the same session, so it is guarded
too.
+ *
+ * @return a usable display, or null when there is none
+ */
+ public static Display currentOrDefault() {
+ Display display = current();
+ if (display != null) {
+ return display;
+ }
+ try {
+ return Display.getDefault();
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
index 502f6ad4aa..77e6c7f43b 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SashFormMemory.java
@@ -27,6 +27,7 @@ import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.history.AuditList;
import org.apache.hop.history.AuditManager;
import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.SessionDisplay;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.SashForm;
import org.eclipse.swt.widgets.Control;
@@ -155,7 +156,7 @@ public final class SashFormMemory {
* startup).
*/
public static void resetAll() {
- Display display = Display.getCurrent();
+ Display display = SessionDisplay.current();
if (display == null || display.isDisposed()) {
return;
}
diff --git a/ui/src/test/java/org/apache/hop/ui/hopgui/SessionDisplayTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/SessionDisplayTest.java
new file mode 100644
index 0000000000..79ef812863
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/SessionDisplayTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+
+import org.eclipse.swt.widgets.Display;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+/**
+ * In Hop Web, asking whether this thread has a display goes through the RAP
session bound to it.
+ * When that session has been destroyed - the browser closed, the user logged
out, the session timed
+ * out - RWT dereferences it unchecked and the question itself throws (issue
#8248). The answer for
+ * such a thread is "no display", not an exception thrown out of a guard
clause.
+ */
+class SessionDisplayTest {
+
+ /** What RWT throws out of {@code LifeCycleUtil.getSessionDisplay} for a
destroyed session. */
+ private static NullPointerException deadSession() {
+ return new NullPointerException(
+ "Cannot invoke
\"org.eclipse.rap.rwt.service.UISession.getAttribute(String)\""
+ + " because \"uiSession\" is null");
+ }
+
+ @Test
+ @DisplayName("a thread whose session is gone has no display, rather than an
exception")
+ void currentAnswersNullWhenTheSessionIsGone() {
+ try (MockedStatic<Display> display = mockStatic(Display.class)) {
+ display.when(Display::getCurrent).thenThrow(deadSession());
+
+ assertNull(SessionDisplay.current());
+ }
+ }
+
+ @Test
+ @DisplayName("the display of a live session is handed back unchanged")
+ void currentAnswersTheDisplayOfTheThread() {
+ Display sessionDisplay = mock(Display.class);
+ try (MockedStatic<Display> display = mockStatic(Display.class)) {
+ display.when(Display::getCurrent).thenReturn(sessionDisplay);
+
+ assertSame(sessionDisplay, SessionDisplay.current());
+ }
+ }
+
+ @Test
+ @DisplayName("the fallback to the default display is guarded the same way")
+ void currentOrDefaultAnswersNullWhenNeitherResolves() {
+ try (MockedStatic<Display> display = mockStatic(Display.class)) {
+ display.when(Display::getCurrent).thenThrow(deadSession());
+ display.when(Display::getDefault).thenThrow(deadSession());
+
+ assertNull(SessionDisplay.currentOrDefault());
+ }
+ }
+
+ @Test
+ @DisplayName("a thread without a display of its own falls back to the
default one")
+ void currentOrDefaultFallsBackToTheDefaultDisplay() {
+ Display defaultDisplay = mock(Display.class);
+ try (MockedStatic<Display> display = mockStatic(Display.class)) {
+ display.when(Display::getCurrent).thenReturn(null);
+ display.when(Display::getDefault).thenReturn(defaultDisplay);
+
+ assertSame(defaultDisplay, SessionDisplay.currentOrDefault());
+ }
+ }
+}