This is an automated email from the ASF dual-hosted git repository.

hansva 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 b750f7c5b8 add option to use menus, fixes #8478 (#8494)
b750f7c5b8 is described below

commit b750f7c5b8e8de2ba47fd65e38b1fcfaab2a8d4f
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Sep 21 18:53:25 2026 +0200

    add option to use menus, fixes #8478 (#8494)
    
    * add option to use menus, fixes #8478
    
    * hardening
---
 .../org/apache/hop/core/svg/HopSvgGraphics2D.java  | 121 ++++++++++++++-
 .../apache/hop/core/svg/HopSvgGraphics2DTest.java  |  47 +++++-
 .../modules/ROOT/pages/hop-gui/canvas-mouse.adoc   |   3 +-
 .../pages/hop-gui/perspective-configuration.adoc   |   4 +
 .../main/java/org/apache/hop/core/gui/SvgGc.java   |  47 +++++-
 .../hop/ui/hopgui/canvas/CanvasGraphRegistry.java  |   4 +-
 .../ui/hopgui/canvas/CanvasInteractionHandler.java |  16 +-
 .../org/apache/hop/ui/hopgui/canvas-svg.js         |  22 +++
 .../org/apache/hop/ui/hopgui/dark-mode.css         |  16 ++
 .../org/apache/hop/ui/hopgui/light-mode.css        |  16 ++
 .../canvas/CanvasInteractionHandlerTest.java       |  73 +++++++++
 .../hop/ui/hopgui/file/GraphCanvasTestBase.java    |  39 +++++
 .../HopGuiPipelineGraphClickContractTest.java      |  61 +++++++-
 .../HopGuiWorkflowGraphClickContractTest.java      |  61 +++++++-
 .../main/java/org/apache/hop/ui/core/PropsUi.java  |  16 ++
 .../org/apache/hop/ui/core/gui/HopToolTip.java     | 136 ++++++++++++++---
 .../apache/hop/ui/core/gui/RoundedShellRegion.java |  93 ++++++++++++
 .../hop/ui/hopgui/context/GuiContextMenu.java      | 167 +++++++++++++++++++++
 .../ui/hopgui/delegates/HopGuiContextDelegate.java |  56 +------
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  28 +++-
 .../ui/hopgui/file/shared/HopGuiAbstractGraph.java |  35 +++++
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  28 +++-
 .../configuration/tabs/ConfigGuiOptionsTab.java    |  14 ++
 .../hop/ui/hopgui/shared/IWebCanvasGraph.java      |   6 +
 .../core/dialog/messages/messages_en_US.properties |   2 +
 .../workflow/messages/messages_en_US.properties    |   1 +
 .../ui/hopgui/messages/messages_en_US.properties   |   1 +
 .../org/apache/hop/ui/core/gui/HopToolTipTest.java |  89 +++++++++++
 28 files changed, 1093 insertions(+), 109 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/svg/HopSvgGraphics2D.java 
b/core/src/main/java/org/apache/hop/core/svg/HopSvgGraphics2D.java
index cfa04fb96b..6418e5a28f 100644
--- a/core/src/main/java/org/apache/hop/core/svg/HopSvgGraphics2D.java
+++ b/core/src/main/java/org/apache/hop/core/svg/HopSvgGraphics2D.java
@@ -24,7 +24,9 @@ import java.awt.font.TextLayout;
 import java.io.StringWriter;
 import java.text.DecimalFormat;
 import java.text.DecimalFormatSymbols;
+import java.util.HashMap;
 import java.util.Locale;
+import java.util.Map;
 import javax.xml.transform.OutputKeys;
 import javax.xml.transform.Transformer;
 import javax.xml.transform.TransformerException;
@@ -33,6 +35,8 @@ import javax.xml.transform.stream.StreamResult;
 import org.apache.batik.anim.dom.SVGDOMImplementation;
 import org.apache.batik.dom.GenericDOMImplementation;
 import org.apache.batik.svggen.DOMGroupManager;
+import org.apache.batik.svggen.DefaultStyleHandler;
+import org.apache.batik.svggen.SVGGeneratorContext;
 import org.apache.batik.svggen.SVGGraphics2D;
 import org.apache.batik.util.SVGConstants;
 import org.apache.hop.core.xml.XmlHandler;
@@ -46,14 +50,129 @@ public class HopSvgGraphics2D extends SVGGraphics2D {
 
   private static final String W3_URL = "http://www.w3.org/2000/xmlns/";;
 
+  /**
+   * The font families a browser is asked to try, in order, for text drawn 
with a sans-serif font.
+   * The canvas painter (SvgGc in the engine) measures text with the first of 
these the JVM has, so
+   * the browser draws with the same family whenever it has it too.
+   */
+  public static final String[] SANS_SERIF_FAMILIES = {
+    "Verdana", "DejaVu Sans", "Lucida Sans", "Lucida Grande", "Arial", 
"Helvetica"
+  };
+
+  private static final String SANS_SERIF_STACK = 
toFontStack(SANS_SERIF_FAMILIES, "sans-serif");
+  private static final String MONOSPACE_STACK =
+      toFontStack(
+          new String[] {"DejaVu Sans Mono", "Menlo", "Consolas", "Lucida 
Console"}, "monospace");
+  private static final String SERIF_STACK =
+      toFontStack(new String[] {"Georgia", "Times New Roman"}, "serif");
+
+  /** Batik's rendering of the AWT logical fonts, mapped to a family stack a 
browser can use. */
+  private static final Map<String, String> LOGICAL_FONT_STACKS = new 
HashMap<>();
+
+  static {
+    LOGICAL_FONT_STACKS.put("'Dialog'", SANS_SERIF_STACK);
+    LOGICAL_FONT_STACKS.put("'SansSerif'", SANS_SERIF_STACK);
+    LOGICAL_FONT_STACKS.put("sans-serif", SANS_SERIF_STACK);
+    LOGICAL_FONT_STACKS.put("'DialogInput'", MONOSPACE_STACK);
+    LOGICAL_FONT_STACKS.put("'Monospaced'", MONOSPACE_STACK);
+    LOGICAL_FONT_STACKS.put("monospace", MONOSPACE_STACK);
+    LOGICAL_FONT_STACKS.put("'Serif'", SERIF_STACK);
+    LOGICAL_FONT_STACKS.put("serif", SERIF_STACK);
+  }
+
   private final DecimalFormat formater;
 
   public HopSvgGraphics2D(Document domFactory) {
-    super(domFactory);
+    super(createGeneratorContext(domFactory), false);
 
     formater = new DecimalFormat("0.###", 
DecimalFormatSymbols.getInstance(Locale.ENGLISH));
   }
 
+  private static SVGGeneratorContext createGeneratorContext(Document 
domFactory) {
+    SVGGeneratorContext context = 
SVGGeneratorContext.createDefault(domFactory);
+    context.setStyleHandler(new WebFontStyleHandler());
+    return context;
+  }
+
+  private static String toFontStack(String[] families, String generic) {
+    StringBuilder stack = new StringBuilder();
+    for (String family : families) {
+      stack.append('\'').append(family).append("', ");
+    }
+    return stack.append(generic).toString();
+  }
+
+  /**
+   * Batik writes the AWT font family into the SVG: for the logical fonts that 
is a name like
+   * 'Dialog' which no browser has, so the browser falls back to its default 
(serif) font. Replace
+   * it with a family stack the browser can honour, and give any other family 
the sans-serif stack
+   * as its fallback.
+   */
+  private static class WebFontStyleHandler extends DefaultStyleHandler {
+    @Override
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    public void setStyle(Element element, Map styleMap, SVGGeneratorContext 
generatorContext) {
+      Object family = styleMap.get(SVGConstants.SVG_FONT_FAMILY_ATTRIBUTE);
+      if (family instanceof String familyString) {
+        Map webStyleMap = new HashMap(styleMap);
+        webStyleMap.put(SVGConstants.SVG_FONT_FAMILY_ATTRIBUTE, 
toWebFontFamily(familyString));
+        styleMap = webStyleMap;
+      }
+      super.setStyle(element, styleMap, generatorContext);
+    }
+  }
+
+  /**
+   * @param family the font family as Batik writes it, quoted for a concrete 
family ('Verdana') or
+   *     bare for a generic one (sans-serif)
+   * @return a font family stack for the browser
+   */
+  static String toWebFontFamily(String family) {
+    String stack = LOGICAL_FONT_STACKS.get(family);
+    if (stack != null) {
+      return stack;
+    }
+    if (family.contains(",")) {
+      return family; // already a stack
+    }
+    if (SANS_SERIF_STACK.contains(family + ",")) {
+      return SANS_SERIF_STACK; // one of the stack's own families: keep the 
order as it is
+    }
+    return family + ", " + SANS_SERIF_STACK;
+  }
+
+  /**
+   * Draw the string like Batik does, but pin the run to the width this JVM 
measured for it. The
+   * browser may not have the font the text was laid out with; with {@code 
textLength} it stretches
+   * or squeezes the letter spacing so that the text still starts and ends 
where the painter put it,
+   * under the borders, hover areas and hop labels that were sized for it.
+   */
+  @Override
+  public void drawString(String s, float x, float y) {
+    if (s == null || s.isEmpty() || getFont() == null || 
getFont().isTransformed()) {
+      super.drawString(s, x, y);
+      return;
+    }
+    double width = getFont().getStringBounds(s, 
getFontRenderContext()).getWidth();
+    if (width <= 0) {
+      super.drawString(s, x, y);
+      return;
+    }
+
+    SVGGeneratorContext context = getGeneratorContext();
+    Element text =
+        getDOMFactory().createElementNS(SVGConstants.SVG_NAMESPACE_URI, 
SVGConstants.SVG_TEXT_TAG);
+    text.setAttributeNS(null, SVGConstants.SVG_X_ATTRIBUTE, 
context.doubleString(x));
+    text.setAttributeNS(null, SVGConstants.SVG_Y_ATTRIBUTE, 
context.doubleString(y));
+    text.setAttributeNS(null, SVGConstants.SVG_TEXT_LENGTH_ATTRIBUTE, 
format(width));
+    text.setAttributeNS(
+        SVGConstants.XML_NAMESPACE_URI,
+        SVGConstants.XML_SPACE_QNAME,
+        SVGConstants.XML_PRESERVE_VALUE);
+    text.appendChild(getDOMFactory().createTextNode(s));
+    getDomGroupManager().addElement(text, FILL);
+  }
+
   public DOMGroupManager getDomGroupManager() {
     return super.getDOMGroupManager();
   }
diff --git 
a/core/src/test/java/org/apache/hop/core/svg/HopSvgGraphics2DTest.java 
b/core/src/test/java/org/apache/hop/core/svg/HopSvgGraphics2DTest.java
index 2c63d11f19..f205ed51da 100644
--- a/core/src/test/java/org/apache/hop/core/svg/HopSvgGraphics2DTest.java
+++ b/core/src/test/java/org/apache/hop/core/svg/HopSvgGraphics2DTest.java
@@ -18,9 +18,14 @@
 package org.apache.hop.core.svg;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import java.awt.Font;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import org.junit.jupiter.api.Test;
 
 class HopSvgGraphics2DTest {
@@ -28,7 +33,7 @@ class HopSvgGraphics2DTest {
   private static final String BASIC_SVG_XML =
       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg fill-opacity=\"1\" 
color-rendering=\"auto\" color-interpolation=\"auto\" text-rendering=\"auto\" "
           + "stroke=\"black\" stroke-linecap=\"square\" 
stroke-miterlimit=\"10\" shape-rendering=\"auto\" stroke-opacity=\"1\" 
fill=\"black\" stroke-dasharray=\"none\" font-weight=\"normal\" "
-          + "stroke-width=\"1\" font-family=\"'Dialog'\" font-style=\"normal\" 
stroke-linejoin=\"miter\" font-size=\"12px\" stroke-dashoffset=\"0\" 
image-rendering=\"auto\" xmlns=\"http://www.w3";
+          + "stroke-width=\"1\" font-family=\"'Verdana', 'DejaVu Sans', 
'Lucida Sans', 'Lucida Grande', 'Arial', 'Helvetica', sans-serif\" 
font-style=\"normal\" stroke-linejoin=\"miter\" font-size=\"12px\" 
stroke-dashoffset=\"0\" image-rendering=\"auto\" xmlns=\"http://www.w3";
           + ".org/2000/svg\">\n"
           + "  <!--Generated by the Batik Graphics2D SVG Generator-->\n"
           + "  <defs id=\"genericDefs\"/>\n"
@@ -38,7 +43,7 @@ class HopSvgGraphics2DTest {
   private static final String BASIC_CIRCLE_XML =
       "<?xml version=\"1.0\" encoding=\"UTF-8\"?><svg fill-opacity=\"1\" 
color-rendering=\"auto\" color-interpolation=\"auto\" text-rendering=\"auto\" "
           + "stroke=\"black\" stroke-linecap=\"square\" 
stroke-miterlimit=\"10\" shape-rendering=\"auto\" stroke-opacity=\"1\" 
fill=\"black\" stroke-dasharray=\"none\" font-weight=\"normal\" "
-          + "stroke-width=\"1\" font-family=\"'Dialog'\" font-style=\"normal\" 
stroke-linejoin=\"miter\" font-size=\"12px\" stroke-dashoffset=\"0\" 
image-rendering=\"auto\" xmlns=\"http://www.w3";
+          + "stroke-width=\"1\" font-family=\"'Verdana', 'DejaVu Sans', 
'Lucida Sans', 'Lucida Grande', 'Arial', 'Helvetica', sans-serif\" 
font-style=\"normal\" stroke-linejoin=\"miter\" font-size=\"12px\" 
stroke-dashoffset=\"0\" image-rendering=\"auto\" xmlns=\"http://www.w3";
           + ".org/2000/svg\">\n"
           + "  <!--Generated by the Batik Graphics2D SVG Generator-->\n"
           + "  <defs id=\"genericDefs\"/>\n"
@@ -69,4 +74,42 @@ class HopSvgGraphics2DTest {
     JsonNode basicSVGNode = xmlMapper.readTree(BASIC_CIRCLE_XML);
     assertEquals(basicSVGNode, graphic2DNode);
   }
+
+  /** A browser has no 'Dialog' font: the logical AWT families become a web 
font stack. */
+  @Test
+  void testLogicalFontFamiliesBecomeWebFontStacks() {
+    
assertTrue(HopSvgGraphics2D.toWebFontFamily("'Dialog'").endsWith("sans-serif"));
+    
assertTrue(HopSvgGraphics2D.toWebFontFamily("sans-serif").endsWith("sans-serif"));
+    
assertTrue(HopSvgGraphics2D.toWebFontFamily("'Monospaced'").endsWith("monospace"));
+    assertTrue(HopSvgGraphics2D.toWebFontFamily("'Serif'").endsWith("serif"));
+    
assertFalse(HopSvgGraphics2D.toWebFontFamily("'Dialog'").contains("Dialog"));
+
+    // A concrete family keeps the lead and gets the sans-serif stack as 
fallback, once.
+    String segoe = HopSvgGraphics2D.toWebFontFamily("'Segoe UI'");
+    assertTrue(segoe.startsWith("'Segoe UI', "));
+    assertTrue(segoe.endsWith("sans-serif"));
+    String verdana = HopSvgGraphics2D.toWebFontFamily("'Verdana'");
+    assertEquals(1, verdana.split("'Verdana'", -1).length - 1);
+  }
+
+  /** The text run is pinned to the width the JVM measured, so the browser 
lays it out the same. */
+  @Test
+  void testDrawStringPinsTextLength() throws Exception {
+    HopSvgGraphics2D graphics2D = HopSvgGraphics2D.newDocument();
+    Font font = new Font(Font.SANS_SERIF, Font.PLAIN, 10);
+    graphics2D.setFont(font);
+    graphics2D.drawString("Table input", 20, 40);
+    String xml = graphics2D.toXml();
+
+    Matcher matcher =
+        Pattern.compile("<text[^>]*textLength=\"([0-9.]+)\"[^>]*>Table 
input</text>").matcher(xml);
+    assertTrue(matcher.find(), xml);
+    double expected =
+        font.getStringBounds("Table input", 
graphics2D.getFontRenderContext()).getWidth();
+    assertEquals(expected, Double.parseDouble(matcher.group(1)), 0.001);
+    assertTrue(xml.contains("x=\"20\""), xml);
+    assertTrue(xml.contains("y=\"40\""), xml);
+    assertFalse(xml.contains("'Dialog'"), xml);
+    assertFalse(xml.contains("'SansSerif'"), xml);
+  }
 }
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/canvas-mouse.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/canvas-mouse.adoc
index 9b16c53fed..6811f44789 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/canvas-mouse.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/canvas-mouse.adoc
@@ -20,10 +20,11 @@ under the License.
 
 = Canvas mouse gestures
 
-The pipeline and workflow canvas react to where you click and with which 
button. This page is the reference for all of it. Two options in the 
xref:hop-gui/perspective-configuration.adoc[configuration perspective] (General 
tab, Canvas section) change the behaviour:
+The pipeline and workflow canvas react to where you click and with which 
button. This page is the reference for all of it. Three options in the 
xref:hop-gui/perspective-configuration.adoc[configuration perspective] (General 
tab, Canvas section) change the behaviour:
 
 * **Use double click on canvas** — off by default. When on, a double-click 
opens the properties of the item under the pointer. A single click then does 
the same as without the option, but only after the double-click interval has 
passed, so that a second click can take over.
 * **Use right click for the context dialog** — off by default. When on, a left 
click never opens the xref:hop-gui/hop-gui-popup-dialog.adoc[context dialog]; a 
right click does. Everything else a left click does (open properties from a 
name, press a badge, select, drag) stays the same.
+* **Use menus instead of the context dialog** — off by default. When on, 
wherever this page says "context dialog" for a transform, action, hop or note, 
a pop-up menu with a submenu per category opens instead. The empty canvas keeps 
the context dialog while the 
xref:hop-gui/hop-gui-popup-dialog.adoc#palette-tree[palette tree] is hidden 
(that is where you search for transforms and actions to add); with the palette 
shown it gets a menu of the pipeline/workflow actions as well.
 
 TIP: On macOS, `Ctrl`-click and a two-finger click both count as a right 
click. Where this page says `Ctrl`, use `Cmd` on macOS.
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
index ddda52ea8d..6bcbb1a34a 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
@@ -95,6 +95,10 @@ See xref:hop-gui/canvas-mouse.adoc[Canvas mouse gestures] 
for every click in bot
 See xref:hop-gui/canvas-mouse.adoc[Canvas mouse gestures] for every click in 
both modes.
 |no
 
+|Use menus instead of the context dialog
+|Transforms, actions, hops and notes show their actions in a pop-up menu with 
a submenu per category instead of the 
xref:hop-gui/hop-gui-popup-dialog.adoc[context dialog]. A click on the empty 
canvas keeps the context dialog while the 
xref:hop-gui/hop-gui-popup-dialog.adoc#palette-tree[palette tree] is hidden, 
since that is where new transforms and actions are searched for; with the 
palette shown the empty canvas gets a menu of the pipeline/workflow actions 
too. The click that opens the  [...]
+|no
+
 |Draw border around names on canvas?
 |If this option is enabled, a border will be drawn around the names of 
transforms and actions on the canvas.
 |no
diff --git a/engine/src/main/java/org/apache/hop/core/gui/SvgGc.java 
b/engine/src/main/java/org/apache/hop/core/gui/SvgGc.java
index 91909725f6..9ae5696332 100644
--- a/engine/src/main/java/org/apache/hop/core/gui/SvgGc.java
+++ b/engine/src/main/java/org/apache/hop/core/gui/SvgGc.java
@@ -22,13 +22,17 @@ import java.awt.BasicStroke;
 import java.awt.Color;
 import java.awt.Dimension;
 import java.awt.Font;
+import java.awt.GraphicsEnvironment;
 import java.awt.Polygon;
 import java.awt.RenderingHints;
 import java.awt.Stroke;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.Rectangle2D;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopPluginException;
@@ -50,7 +54,13 @@ import org.w3c.dom.NodeList;
 import org.w3c.dom.svg.SVGDocument;
 
 public class SvgGc implements IGc {
-  private static final String CONST_FREESANS = "FreeSans";
+  /**
+   * The family the canvas text is measured with: the first of {@link
+   * HopSvgGraphics2D#SANS_SERIF_FAMILIES} this JVM has, so that the browser, 
which is asked for the
+   * same families in the same order, lays the text out the way it was 
measured. Falls back to the
+   * logical SansSerif font, which Batik writes as the generic "sans-serif".
+   */
+  private static volatile String graphFontFamily;
 
   private static SvgFile imageLocked;
   private static SvgFile imageFailure;
@@ -292,11 +302,12 @@ public class SvgGc implements IGc {
     imageArrowDisabled =
         new SvgFile("ui/images/hop-arrow-disabled.svg", 
this.getClass().getClassLoader());
 
-    fontGraph = new Font(CONST_FREESANS, Font.PLAIN, 10);
-    fontGraphBold = new Font(CONST_FREESANS, Font.BOLD, 10);
-    fontNote = new Font(CONST_FREESANS, Font.PLAIN, 10);
-    fontSmall = new Font(CONST_FREESANS, Font.PLAIN, 8);
-    fontTiny = new Font(CONST_FREESANS, Font.PLAIN, 6);
+    String family = getGraphFontFamily();
+    fontGraph = new Font(family, Font.PLAIN, 10);
+    fontGraphBold = new Font(family, Font.BOLD, 10);
+    fontNote = new Font(family, Font.PLAIN, 10);
+    fontSmall = new Font(family, Font.PLAIN, 8);
+    fontTiny = new Font(family, Font.PLAIN, 6);
 
     gc.setFont(fontGraph);
 
@@ -304,6 +315,30 @@ public class SvgGc implements IGc {
     gc.fillRect(0, 0, area.x, area.y);
   }
 
+  private static String getGraphFontFamily() {
+    String family = graphFontFamily;
+    if (family == null) {
+      family = Font.SANS_SERIF;
+      try {
+        Set<String> available =
+            new HashSet<>(
+                Arrays.asList(
+                    GraphicsEnvironment.getLocalGraphicsEnvironment()
+                        .getAvailableFontFamilyNames()));
+        for (String candidate : HopSvgGraphics2D.SANS_SERIF_FAMILIES) {
+          if (available.contains(candidate)) {
+            family = candidate;
+            break;
+          }
+        }
+      } catch (Throwable e) {
+        // No font configuration on this system: the logical font will have to 
do.
+      }
+      graphFontFamily = family;
+    }
+    return family;
+  }
+
   @Override
   public void dispose() {
     // Do nothing
diff --git 
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasGraphRegistry.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasGraphRegistry.java
index 2d130b5145..91e9281c2f 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasGraphRegistry.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasGraphRegistry.java
@@ -53,7 +53,9 @@ public class CanvasGraphRegistry {
   }
 
   public void register(String canvasId, Canvas canvas, Object graph) {
-    canvasById.put(canvasId, canvas);
+    if (canvas != null) {
+      canvasById.put(canvasId, canvas);
+    }
     graphsByCanvasId.put(canvasId, graph);
   }
 
diff --git 
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
 
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
index 4b36d18591..05d383e290 100644
--- 
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
+++ 
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandler.java
@@ -65,7 +65,7 @@ public class CanvasInteractionHandler extends Widget {
             @Override
             public void handleNotify(String event, JsonObject properties) {
               if ("hover".equals(event)) {
-                handleHover(properties);
+                handleHover(CanvasGraphRegistry.getInstance(), properties);
               }
             }
           });
@@ -77,15 +77,25 @@ public class CanvasInteractionHandler extends Widget {
     }
   }
 
-  private static void handleHover(JsonObject properties) {
+  /**
+   * Route a client hover notification to the graph of its canvas: coordinates 
while the pointer is
+   * over something with a tooltip, {@code leave: true} once it moved off it 
or off the canvas.
+   */
+  static void handleHover(CanvasGraphRegistry registry, JsonObject properties) 
{
     if (properties.get("canvasId") == null) {
       return;
     }
     String canvasId = properties.get("canvasId").asString();
-    Object graph = CanvasGraphRegistry.getInstance().getGraph(canvasId);
+    Object graph = registry.getGraph(canvasId);
     if (graph == null) {
       return;
     }
+    if (properties.get("leave") != null && 
properties.get("leave").asBoolean()) {
+      if (graph instanceof IWebCanvasGraph webCanvasGraph) {
+        webCanvasGraph.handleWebCanvasHoverEnd();
+      }
+      return;
+    }
     int graphX = properties.get("graphX").asInt();
     int graphY = properties.get("graphY").asInt();
     int screenX = properties.get("screenX") != null ? 
properties.get("screenX").asInt() : graphX;
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
index 8ce8ceecd0..d22a1d9791 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
@@ -335,6 +335,7 @@
         this._mousemoveHandler = null;
         this._mouseleaveHandler = null;
         this._lastHoverKey = null;
+        this._hoverNotified = false;
         this._svgHost = null;
         this._effectsLayer = null;
         this._dragPreviewRects = null;
@@ -582,6 +583,7 @@
             this._mouseleaveHandler = function () {
                 if (!self._dragActive && !self._panActive && 
!self._navDragActive && !self._selectActive) {
                     self._lastHoverKey = null;
+                    self._notifyHoverEnd();
                     self._updateHoverChrome(null);
                     self._clearNoteResizeHandles();
                     self._clearHopLine();
@@ -2308,6 +2310,7 @@
             this._lastHoverKey = hoverKey;
             var interaction = hop._canvasInteractions && 
hop._canvasInteractions[this._canvasId];
             if (area && area.hover && interaction && 
interaction._remoteObject) {
+                this._hoverNotified = true;
                 interaction._remoteObject.notify("hover", {
                     canvasId: this._canvasId,
                     graphX: graph.x,
@@ -2315,6 +2318,25 @@
                     screenX: Math.round(screenX),
                     screenY: Math.round(screenY)
                 });
+            } else {
+                // Left the hovered area for empty canvas or something without 
a tooltip: the
+                // server only ever hears about the entering, so tell it to 
take the tooltip down.
+                this._notifyHoverEnd();
+            }
+        },
+
+        /** Tell the server the hover ended, once per hover the server was 
told about. */
+        _notifyHoverEnd: function () {
+            if (!this._hoverNotified) {
+                return;
+            }
+            this._hoverNotified = false;
+            var interaction = hop._canvasInteractions && 
hop._canvasInteractions[this._canvasId];
+            if (interaction && interaction._remoteObject) {
+                interaction._remoteObject.notify("hover", {
+                    canvasId: this._canvasId,
+                    leave: true
+                });
             }
         }
     };
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
index 30ffa9cbc4..f4226a5b0e 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
@@ -2532,6 +2532,22 @@ Label.sidebarButton {
     border-radius: 6px;
 }
 
+/* Canvas tooltip (HopToolTip): an inverted, rounded pill */
+
+Shell.hopToolTip {
+    border: none;
+    border-radius: 6px;
+    background-color: #f3f4f6;
+    color: #1e1e1e;
+    padding: 0;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+}
+
+Label.hopToolTip {
+    color: #1e1e1e;
+    background-color: #f3f4f6;
+}
+
 
 /* JFace specific theming */
 
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
index 03bf96e7e3..110c270ff7 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
@@ -2538,6 +2538,22 @@ Label.sidebarButton {
     border-radius: 6px;
 }
 
+/* Canvas tooltip (HopToolTip): an inverted, rounded pill */
+
+Shell.hopToolTip {
+    border: none;
+    border-radius: 6px;
+    background-color: #2b2d30;
+    color: #f2f2f2;
+    padding: 0;
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+Label.hopToolTip {
+    color: #f2f2f2;
+    background-color: #2b2d30;
+}
+
 /* JFace specific theming */
 
 Shell.jface_contentProposalPopup, Shell.jface_infoPopupDialog {
diff --git 
a/rap/src/test/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandlerTest.java
 
b/rap/src/test/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandlerTest.java
new file mode 100644
index 0000000000..bc534ae9ef
--- /dev/null
+++ 
b/rap/src/test/java/org/apache/hop/ui/hopgui/canvas/CanvasInteractionHandlerTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.canvas;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
+import org.eclipse.rap.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+class CanvasInteractionHandlerTest {
+
+  /** Records what the client hover notifications ask of the graph. */
+  private static class RecordingGraph implements IWebCanvasGraph {
+    final List<String> calls = new ArrayList<>();
+
+    @Override
+    public void replaceAreaOwners(List<AreaOwner> owners) {}
+
+    @Override
+    public void handleWebCanvasHover(int graphX, int graphY, int screenX, int 
screenY) {
+      calls.add("hover " + graphX + "," + graphY + " @" + screenX + "," + 
screenY);
+    }
+
+    @Override
+    public void handleWebCanvasHoverEnd() {
+      calls.add("end");
+    }
+  }
+
+  @Test
+  void hoverCoordinatesReachTheGraphAndLeaveHidesTheTooltip() {
+    CanvasGraphRegistry registry = new CanvasGraphRegistry();
+    RecordingGraph graph = new RecordingGraph();
+    registry.register("c1", null, graph);
+
+    CanvasInteractionHandler.handleHover(
+        registry,
+        new JsonObject()
+            .add("canvasId", "c1")
+            .add("graphX", 10)
+            .add("graphY", 20)
+            .add("screenX", 110)
+            .add("screenY", 120));
+    // The pointer moved off the transform: the tooltip used to stay behind 
(no leave message).
+    CanvasInteractionHandler.handleHover(
+        registry, new JsonObject().add("canvasId", "c1").add("leave", true));
+    // Unknown canvas and missing canvas id are ignored
+    CanvasInteractionHandler.handleHover(
+        registry, new JsonObject().add("canvasId", "nope").add("leave", true));
+    CanvasInteractionHandler.handleHover(registry, new 
JsonObject().add("leave", true));
+
+    assertEquals(List.of("hover 10,20 @110,120", "end"), graph.calls);
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
index 7de3e34f16..eef8e02ee9 100644
--- a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
+++ b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/GraphCanvasTestBase.java
@@ -345,9 +345,28 @@ public abstract class GraphCanvasTestBase extends 
SwtBotTestBase {
    */
   protected List<String> clickAndCatchDialogs(
       SWTBot bot, Canvas canvas, double scale, Point at, int button, int 
stateMask) {
+    return clickAndCatchDialogs(bot, canvas, scale, at, button, stateMask, () 
-> {});
+  }
+
+  /**
+   * Like {@link #clickAndCatchDialogs(SWTBot, Canvas, double, Point, int, 
int)}, and runs {@code
+   * landed} on this thread once the canvas has handled both halves of the 
click, before the wait
+   * for dialogs starts. That wait takes a couple of seconds when nothing 
opens, which is too late
+   * to look at what the click leaves behind only briefly, like a balloon on a 
timer.
+   */
+  protected List<String> clickAndCatchDialogs(
+      SWTBot bot,
+      Canvas canvas,
+      double scale,
+      Point at,
+      int button,
+      int stateMask,
+      Runnable landed) {
     Set<Shell> before = openShells();
     fireAsync(canvas, SWT.MouseDown, scale, at, button, stateMask);
     fireAsync(canvas, SWT.MouseUp, scale, at, button, stateMask | 
buttonMask(button));
+    awaitPostedEvents();
+    landed.run();
     return catchDialogs(bot, before);
   }
 
@@ -359,13 +378,33 @@ public abstract class GraphCanvasTestBase extends 
SwtBotTestBase {
    */
   protected List<String> contextClickAndCatchDialogs(
       SWTBot bot, Canvas canvas, double scale, Point at) {
+    return contextClickAndCatchDialogs(bot, canvas, scale, at, () -> {});
+  }
+
+  /**
+   * Like {@link #contextClickAndCatchDialogs(SWTBot, Canvas, double, Point)}, 
with a {@code landed}
+   * hook as in {@link #clickAndCatchDialogs(SWTBot, Canvas, double, Point, 
int, int, Runnable)}.
+   */
+  protected List<String> contextClickAndCatchDialogs(
+      SWTBot bot, Canvas canvas, double scale, Point at, Runnable landed) {
     Set<Shell> before = openShells();
     fireAsync(canvas, SWT.MouseDown, scale, at, 3, SWT.NONE);
     fireMenuDetectAsync(canvas, scale, at);
     fireAsync(canvas, SWT.MouseUp, scale, at, 3, SWT.BUTTON3);
+    awaitPostedEvents();
+    landed.run();
     return catchDialogs(bot, before);
   }
 
+  /**
+   * Returns once every event posted so far has been dispatched. The display 
runs its queue in
+   * order, so a round trip posted after the events comes back after their 
handlers ran; a dialog
+   * one of them opened dispatches the round trip from its own loop, so this 
cannot deadlock.
+   */
+  private void awaitPostedEvents() {
+    onUi(() -> {});
+  }
+
   /**
    * {@code MenuDetect} carries display coordinates, so the graph point is 
mapped on the UI thread.
    */
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphClickContractTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphClickContractTest.java
index 213e151359..76676b5b5f 100644
--- 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphClickContractTest.java
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphClickContractTest.java
@@ -18,6 +18,7 @@ package org.apache.hop.ui.hopgui.file.pipeline;
 
 import static org.junit.jupiter.api.Assertions.assertAll;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.LinkedHashMap;
@@ -50,6 +51,7 @@ import org.eclipse.swt.widgets.Canvas;
 import org.eclipse.swtbot.swt.finder.SWTBot;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
@@ -206,8 +208,10 @@ class HopGuiPipelineGraphClickContractTest extends 
GraphCanvasTestBase {
 
           List<String> dialogs =
               cell.button == RIGHT
-                  ? contextClickAndCatchDialogs(scene.bot, scene.canvas, 
scene.scale, at)
-                  : clickAndCatchDialogs(scene.bot, scene.canvas, scene.scale, 
at, LEFT, SWT.NONE);
+                  ? contextClickAndCatchDialogs(
+                      scene.bot, scene.canvas, scene.scale, at, 
scene::noteBalloon)
+                  : clickAndCatchDialogs(
+                      scene.bot, scene.canvas, scene.scale, at, LEFT, 
SWT.NONE, scene::noteBalloon);
 
           assertAll(
               () -> assertDialogs(cell.dialog, dialogs),
@@ -217,6 +221,40 @@ class HopGuiPipelineGraphClickContractTest extends 
GraphCanvasTestBase {
         });
   }
 
+  /**
+   * The "Selection cleared" balloon is a notice, not a hover tooltip: moving 
the mouse over the
+   * empty canvas must leave it up, its timer takes it down.
+   */
+  @Test
+  void movingTheMouseKeepsTheSelectionClearedBalloon() {
+    Cell cell = new Cell(false, Where.EMPTY_SELECTED, LEFT, null, 
Scene::selectionCleared);
+    onCanvas(
+        cell,
+        scene -> {
+          Point at = scene.aim(cell.where);
+          Point nearby = new Point(at.x + 10, at.y + 10);
+          assertEmptyCanvas(scene.lookup, nearby);
+
+          List<String> dialogs =
+              clickAndCatchDialogs(
+                  scene.bot,
+                  scene.canvas,
+                  scene.scale,
+                  at,
+                  LEFT,
+                  SWT.NONE,
+                  () -> {
+                    fire(scene.canvas, SWT.MouseMove, scene.scale, nearby, 0, 
SWT.NONE);
+                    scene.noteBalloon();
+                  });
+
+          assertAll(
+              () -> assertDialogs(null, dialogs),
+              () -> cell.sideEffect.accept(scene),
+              () -> assertNoFailures());
+        });
+  }
+
   // ------------------------------------------------------------------ 
assertions
 
   private static void assertDialogs(String expected, List<String> actual) {
@@ -353,14 +391,25 @@ class HopGuiPipelineGraphClickContractTest extends 
GraphCanvasTestBase {
       assertTrue(note.isSelected(), "the click should have selected the note");
     }
 
+    /**
+     * The text of the balloon the click put up, or null when it put up none. 
Taken the moment the
+     * click has landed: the balloon is on a timer, so by the time the wait 
for dialogs is over it
+     * may well be gone again.
+     */
+    String balloon;
+
+    void noteBalloon() {
+      HopToolTip toolTip = (HopToolTip) privateField(graph, "toolTip");
+      balloon = onUi(() -> toolTip.isVisible() ? toolTip.getText() : null);
+    }
+
     /** The manual promises the selection is gone and a "Selection cleared" 
balloon says so. */
     void selectionCleared() {
       nothingSelected();
-      HopToolTip toolTip = (HopToolTip) privateField(graph, "toolTip");
-      assertTrue(onUi(toolTip::isVisible), "the 'Selection cleared' balloon 
should be showing");
+      assertNotNull(balloon, "the click should have put up the 'Selection 
cleared' balloon");
       assertTrue(
-          onUi(toolTip::getText).contains("Selection cleared"),
-          "the balloon should say the selection was cleared, not " + 
onUi(toolTip::getText));
+          balloon.contains("Selection cleared"),
+          "the balloon should say the selection was cleared, not " + balloon);
     }
 
     void nothingSelected() {
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphClickContractTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphClickContractTest.java
index 7cf4451d43..a71ea2c65f 100644
--- 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphClickContractTest.java
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraphClickContractTest.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.hopgui.file.workflow;
 import static org.junit.jupiter.api.Assertions.assertAll;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.LinkedHashMap;
@@ -52,6 +53,7 @@ import org.eclipse.swt.widgets.Canvas;
 import org.eclipse.swtbot.swt.finder.SWTBot;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.MethodSource;
 
@@ -205,8 +207,10 @@ class HopGuiWorkflowGraphClickContractTest extends 
GraphCanvasTestBase {
 
           List<String> dialogs =
               cell.button == RIGHT
-                  ? contextClickAndCatchDialogs(scene.bot, scene.canvas, 
scene.scale, at)
-                  : clickAndCatchDialogs(scene.bot, scene.canvas, scene.scale, 
at, LEFT, SWT.NONE);
+                  ? contextClickAndCatchDialogs(
+                      scene.bot, scene.canvas, scene.scale, at, 
scene::noteBalloon)
+                  : clickAndCatchDialogs(
+                      scene.bot, scene.canvas, scene.scale, at, LEFT, 
SWT.NONE, scene::noteBalloon);
 
           assertAll(
               () -> assertDialogs(cell.dialog, dialogs),
@@ -216,6 +220,40 @@ class HopGuiWorkflowGraphClickContractTest extends 
GraphCanvasTestBase {
         });
   }
 
+  /**
+   * The "Selection cleared" balloon is a notice, not a hover tooltip: moving 
the mouse over the
+   * empty canvas must leave it up, its timer takes it down.
+   */
+  @Test
+  void movingTheMouseKeepsTheSelectionClearedBalloon() {
+    Cell cell = new Cell(false, Where.EMPTY_SELECTED, LEFT, null, 
Scene::selectionCleared);
+    onCanvas(
+        cell,
+        scene -> {
+          Point at = scene.aim(cell.where);
+          Point nearby = new Point(at.x + 10, at.y + 10);
+          assertEmptyCanvas(scene.lookup, nearby);
+
+          List<String> dialogs =
+              clickAndCatchDialogs(
+                  scene.bot,
+                  scene.canvas,
+                  scene.scale,
+                  at,
+                  LEFT,
+                  SWT.NONE,
+                  () -> {
+                    fire(scene.canvas, SWT.MouseMove, scene.scale, nearby, 0, 
SWT.NONE);
+                    scene.noteBalloon();
+                  });
+
+          assertAll(
+              () -> assertDialogs(null, dialogs),
+              () -> cell.sideEffect.accept(scene),
+              () -> assertNoFailures());
+        });
+  }
+
   // ------------------------------------------------------------------ 
assertions
 
   private static void assertDialogs(String expected, List<String> actual) {
@@ -343,14 +381,25 @@ class HopGuiWorkflowGraphClickContractTest extends 
GraphCanvasTestBase {
       assertTrue(note.isSelected(), "the click should have selected the note");
     }
 
+    /**
+     * The text of the balloon the click put up, or null when it put up none. 
Taken the moment the
+     * click has landed: the balloon is on a timer, so by the time the wait 
for dialogs is over it
+     * may well be gone again.
+     */
+    String balloon;
+
+    void noteBalloon() {
+      HopToolTip toolTip = (HopToolTip) privateField(graph, "toolTip");
+      balloon = onUi(() -> toolTip.isVisible() ? toolTip.getText() : null);
+    }
+
     /** The manual promises the selection is gone and a "Selection cleared" 
balloon says so. */
     void selectionCleared() {
       nothingSelected();
-      HopToolTip toolTip = (HopToolTip) privateField(graph, "toolTip");
-      assertTrue(onUi(toolTip::isVisible), "the 'Selection cleared' balloon 
should be showing");
+      assertNotNull(balloon, "the click should have put up the 'Selection 
cleared' balloon");
       assertTrue(
-          onUi(toolTip::getText).contains("Selection cleared"),
-          "the balloon should say the selection was cleared, not " + 
onUi(toolTip::getText));
+          balloon.contains("Selection cleared"),
+          "the balloon should say the selection was cleared, not " + balloon);
     }
 
     void nothingSelected() {
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java 
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index 38b34a423d..50bc949d55 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -84,6 +84,8 @@ public class PropsUi extends Props {
   private static final String DISABLE_BROWSER_ENVIRONMENT_CHECK = 
"DisableBrowserEnvironmentCheck";
   private static final String USE_DOUBLE_CLICK_ON_CANVAS = 
"UseDoubleClickOnCanvas";
   private static final String USE_RIGHT_CLICK_FOR_CONTEXT_DIALOG = 
"UseRightClickForContextDialog";
+  private static final String USE_MENUS_INSTEAD_OF_CONTEXT_DIALOG =
+      "UseMenusInsteadOfContextDialog";
   private static final String DIALOGS_ON_ANY_SCREEN = "DialogsOnAnyScreen";
   private static final String DRAW_BORDER_AROUND_CANVAS_NAMES = 
"DrawBorderAroundCanvasNames";
   private static final String USE_GLOBAL_FILE_BOOKMARKS = 
"UseGlobalFileBookmarks";
@@ -1185,6 +1187,20 @@ public class PropsUi extends Props {
     setProperty(USE_RIGHT_CLICK_FOR_CONTEXT_DIALOG, use ? YES : NO);
   }
 
+  /**
+   * When set, the pipeline and workflow canvas show the actions of a 
transform, action, hop or note
+   * as a pop-up menu instead of the context dialog. A click on the empty 
canvas keeps the context
+   * dialog while the design palette is hidden: that is where new transforms 
and actions are
+   * searched for. With the palette shown the empty canvas gets a menu as well.
+   */
+  public boolean useMenusInsteadOfContextDialog() {
+    return 
YES.equalsIgnoreCase(getProperty(USE_MENUS_INSTEAD_OF_CONTEXT_DIALOG, NO));
+  }
+
+  public void setUseMenusInsteadOfContextDialog(boolean use) {
+    setProperty(USE_MENUS_INSTEAD_OF_CONTEXT_DIALOG, use ? YES : NO);
+  }
+
   /**
    * macOS only. Off (the default): transform, action and metadata dialogs are 
child windows that
    * follow the Hop window and stay above it. On: they are modal windows of 
their own that can be
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/HopToolTip.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/HopToolTip.java
index 2f60a1eeba..10ccf700ba 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/HopToolTip.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/HopToolTip.java
@@ -6,7 +6,7 @@
  * (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
+ *      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,
@@ -17,10 +17,14 @@
 
 package org.apache.hop.ui.core.gui;
 
+import java.util.ArrayList;
+import java.util.List;
 import lombok.Setter;
-import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.core.Const;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Color;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.layout.FillLayout;
 import org.eclipse.swt.widgets.Display;
@@ -28,41 +32,72 @@ import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Shell;
 
 /**
- * A custom tooltip implementation using a Shell and Label that supports 
proper dark mode styling.
- * This replaces the SWT ToolTip widget which doesn't support 
background/foreground color
- * customization.
+ * The canvas tooltip: a rounded "pill" in the inverted colours of the theme 
(dark on the light
+ * theme, light on the dark theme), the way macOS and VS Code show theirs. It 
is a Shell of our own
+ * because the SWT ToolTip widget cannot be coloured, which broke it in dark 
mode (issue #2665).
+ *
+ * <p>Desktop SWT gets the rounded corners from a shell region; Hop Web has no 
regions and gets them
+ * from the {@code Shell.hopToolTip} rule in its theme CSS instead.
  */
 public class HopToolTip {
 
+  /** Custom variant of the tooltip shell and label, styled in the Hop Web 
theme CSS. */
+  public static final String CUSTOM_VARIANT = "hopToolTip";
+
+  private static final String RWT_CUSTOM_VARIANT = 
"org.eclipse.rap.rwt.customVariant";
+
+  /** Corner radius before zoom. */
+  private static final int CORNER_RADIUS = 6;
+
   private final Shell tipShell;
   private final Label tipLabel;
+  private final boolean web;
   @Setter private boolean autoHide = true;
 
+  /** Bumped on every setText/setVisible so that an older hide timer knows it 
is stale. */
+  private int generation;
+
+  /**
+   * True while a notice shown with {@link #hideAfter(int)} is up. A notice is 
not tied to what is
+   * under the pointer, so a mouse move must not take it down: its timer does, 
or whatever is shown
+   * or hidden next.
+   */
+  private boolean notice;
+
   /**
    * Creates a new custom tooltip
    *
    * @param parent The parent shell
    */
   public HopToolTip(Shell parent) {
-    tipShell = new Shell(parent, SWT.ON_TOP | SWT.NO_FOCUS | SWT.TOOL);
+    web = EnvironmentUtils.getInstance().isWeb();
+
+    tipShell = new Shell(parent, SWT.NO_TRIM | SWT.ON_TOP | SWT.NO_FOCUS | 
SWT.TOOL);
+    tipShell.setData(RWT_CUSTOM_VARIANT, CUSTOM_VARIANT);
+    double zoom = PropsUi.getInstance().getZoomFactor();
     FillLayout layout = new FillLayout();
-    layout.marginWidth = ConstUi.SMALL_MARGIN;
-    layout.marginHeight = ConstUi.SMALL_MARGIN;
+    layout.marginWidth = (int) Math.round(10 * zoom);
+    layout.marginHeight = (int) Math.round(6 * zoom);
     tipShell.setLayout(layout);
 
     tipLabel = new Label(tipShell, SWT.NONE);
+    tipLabel.setData(RWT_CUSTOM_VARIANT, CUSTOM_VARIANT);
 
-    // Apply dark mode styling
+    // Inverted colours: a dark pill on the light theme, a light pill on the 
dark theme.
+    //
     GuiResource gui = GuiResource.getInstance();
+    Color background;
+    Color foreground;
     if (PropsUi.getInstance().isDarkMode()) {
-      tipShell.setBackground(gui.getColor(32, 31, 27)); // Dark background
-      tipLabel.setBackground(gui.getColor(32, 31, 27));
-      tipLabel.setForeground(gui.getColor(224, 224, 224)); // Light text
+      background = gui.getColor(243, 244, 246);
+      foreground = gui.getColor(30, 30, 30);
     } else {
-      tipShell.setBackground(gui.getColorWhite()); // Light background
-      tipLabel.setBackground(gui.getColorWhite());
-      tipLabel.setForeground(gui.getColorBlack()); // Dark text
+      background = gui.getColor(43, 45, 48);
+      foreground = gui.getColor(242, 242, 242);
     }
+    tipShell.setBackground(background);
+    tipLabel.setBackground(background);
+    tipLabel.setForeground(foreground);
 
     // Auto-hide when user clicks anywhere
     Display.getCurrent()
@@ -76,15 +111,45 @@ public class HopToolTip {
   }
 
   /**
-   * Sets the tooltip text
+   * Sets the tooltip text. Lines that only draw an ASCII rule ("-----") are 
dropped: the pill has
+   * no room for decoration.
    *
    * @param text The text to display
    */
   public void setText(String text) {
     if (tipLabel != null && !tipLabel.isDisposed()) {
-      tipLabel.setText(text != null ? text : "");
+      generation++;
+      notice = false;
+      tipLabel.setText(cleanText(text));
       tipShell.pack();
+      if (!web) {
+        Point size = tipShell.getSize();
+        int radius = (int) Math.round(CORNER_RADIUS * 
PropsUi.getInstance().getZoomFactor());
+        RoundedShellRegion.apply(tipShell, size.x, size.y, radius);
+      }
+    }
+  }
+
+  static String cleanText(String text) {
+    if (text == null) {
+      return "";
+    }
+    List<String> lines = new ArrayList<>();
+    for (String line : text.split("\r?\n")) {
+      String trimmed = line.strip();
+      if (!trimmed.isEmpty() && trimmed.chars().allMatch(c -> c == '-' || c == 
'=')) {
+        continue; // an ASCII rule
+      }
+      lines.add(line.stripTrailing());
+    }
+    // Drop the surrounding blank lines some tooltips use for spacing: the 
padding does that now.
+    while (!lines.isEmpty() && lines.get(0).isBlank()) {
+      lines.remove(0);
+    }
+    while (!lines.isEmpty() && lines.get(lines.size() - 1).isBlank()) {
+      lines.remove(lines.size() - 1);
     }
+    return String.join(Const.CR, lines).strip();
   }
 
   /**
@@ -127,10 +192,47 @@ public class HopToolTip {
    */
   public void setVisible(boolean visible) {
     if (tipShell != null && !tipShell.isDisposed()) {
+      generation++;
+      notice = false;
       tipShell.setVisible(visible);
     }
   }
 
+  /**
+   * Turn the tooltip that is showing into a notice: hide it after a delay, 
unless it was changed or
+   * hidden and shown again in the meantime. For notices like "Selection 
cleared" that are not tied
+   * to something under the pointer. Call it after the tooltip was shown.
+   *
+   * @param millis the delay in milliseconds
+   * @see #isNotice()
+   */
+  public void hideAfter(int millis) {
+    if (tipShell == null || tipShell.isDisposed()) {
+      return;
+    }
+    notice = true;
+    final int shown = generation;
+    tipShell
+        .getDisplay()
+        .timerExec(
+            millis,
+            () -> {
+              if (shown == generation && isVisible()) {
+                setVisible(false);
+              }
+            });
+  }
+
+  /**
+   * Whether a notice shown with {@link #hideAfter(int)} is still up. The 
canvas leaves such a
+   * notice alone on mouse moves and lets its timer take it down.
+   *
+   * @return true while a notice is showing
+   */
+  public boolean isNotice() {
+    return notice && isVisible();
+  }
+
   /**
    * Checks if the tooltip is visible
    *
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/gui/RoundedShellRegion.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/RoundedShellRegion.java
new file mode 100644
index 0000000000..cdd4b44b97
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/RoundedShellRegion.java
@@ -0,0 +1,93 @@
+/*
+ * 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.core.gui;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Region;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Clips a {@code SWT.NO_TRIM} shell to a rounded rectangle. Desktop SWT only: 
Hop Web (RAP) has no
+ * {@link Region}, which is why this lives in a class of its own that is only 
loaded on the desktop.
+ */
+final class RoundedShellRegion {
+
+  /** Points per corner arc: enough for a radius of a dozen pixels or so. */
+  private static final int ARC_STEPS = 8;
+
+  private static final String DATA_DISPOSE_HOOK = 
"RoundedShellRegion.disposeHook";
+
+  private RoundedShellRegion() {}
+
+  /**
+   * Give the shell a rounded outline, disposing the previous region when 
there was one.
+   *
+   * @param shell the shell to clip
+   * @param width the shell width
+   * @param height the shell height
+   * @param radius the corner radius in pixels
+   */
+  static void apply(Shell shell, int width, int height, int radius) {
+    if (shell == null || shell.isDisposed() || width <= 0 || height <= 0) {
+      return;
+    }
+    Region previous = shell.getRegion();
+    Region region = new Region(shell.getDisplay());
+    region.add(roundedRectangle(width, height, Math.min(radius, 
Math.min(width, height) / 2)));
+    shell.setRegion(region);
+    if (previous != null && !previous.isDisposed()) {
+      previous.dispose();
+    }
+    // Dispose whatever region the shell holds when the shell goes: hooked 
once per shell.
+    if (shell.getData(DATA_DISPOSE_HOOK) == null) {
+      shell.setData(DATA_DISPOSE_HOOK, Boolean.TRUE);
+      shell.addListener(
+          SWT.Dispose,
+          event -> {
+            Region current = shell.getRegion();
+            if (current != null && !current.isDisposed()) {
+              current.dispose();
+            }
+          });
+    }
+  }
+
+  /** The outline as a polygon: four quarter arcs, walked clockwise from the 
top-left corner. */
+  static int[] roundedRectangle(int width, int height, int radius) {
+    if (radius <= 0) {
+      return new int[] {0, 0, width, 0, width, height, 0, height};
+    }
+    int[] points = new int[4 * (ARC_STEPS + 1) * 2];
+    int i = 0;
+    // centre of each corner arc and the start angle of that arc, clockwise 
from top-left
+    int[][] corners = {
+      {radius, radius, 180},
+      {width - radius, radius, 270},
+      {width - radius, height - radius, 0},
+      {radius, height - radius, 90}
+    };
+    for (int[] corner : corners) {
+      for (int step = 0; step <= ARC_STEPS; step++) {
+        double angle = Math.toRadians(corner[2] + 90.0 * step / ARC_STEPS);
+        points[i++] = (int) Math.round(corner[0] + radius * Math.cos(angle));
+        points[i++] = (int) Math.round(corner[1] + radius * Math.sin(angle));
+      }
+    }
+    return points;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextMenu.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextMenu.java
new file mode 100644
index 0000000000..6759173a7d
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextMenu.java
@@ -0,0 +1,167 @@
+/*
+ * 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.context;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.plugin.IGuiActionLambda;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.util.SwtErrorHandler;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Shows the actions of an {@link IGuiContextHandler} as a native SWT pop-up 
menu instead of the
+ * {@link org.apache.hop.ui.core.dialog.ContextDialog}: the "use menus instead 
of the context
+ * dialog" canvas option. Actions with a category go into a submenu per 
category, ordered like the
+ * context dialog orders its category headers; actions without a category are 
listed at the top
+ * level below them.
+ */
+public class GuiContextMenu {
+
+  private GuiContextMenu() {}
+
+  /**
+   * Show the actions of the context handler as a pop-up menu at the given 
display location.
+   *
+   * @param parent the shell owning the menu, also the parent of error dialogs
+   * @param contextHandler the handler supplying the actions
+   * @param displayX the x coordinate on the display
+   * @param displayY the y coordinate on the display
+   * @return true when a menu was shown, false when there were no actions to 
show
+   */
+  public static boolean show(
+      Shell parent, IGuiContextHandler contextHandler, int displayX, int 
displayY) {
+    List<GuiAction> actions =
+        
GuiContextUtil.getInstance().filterAllowedActions(contextHandler.getSupportedActions());
+    if (actions.isEmpty()) {
+      return false;
+    }
+
+    Menu menu = new Menu(parent, SWT.POP_UP);
+
+    // Group by category, keeping the first-seen order of the actions inside a 
category and
+    // sorting the categories by their order like the context dialog does.
+    //
+    Map<String, List<GuiAction>> byCategory = new LinkedHashMap<>();
+    Map<String, String> categoryOrder = new LinkedHashMap<>();
+    List<GuiAction> uncategorized = new ArrayList<>();
+    for (GuiAction action : actions) {
+      String category = action.getCategory();
+      if (StringUtils.isEmpty(category)) {
+        uncategorized.add(action);
+        continue;
+      }
+      byCategory.computeIfAbsent(category, k -> new ArrayList<>()).add(action);
+      categoryOrder.putIfAbsent(category, Const.NVL(action.getCategoryOrder(), 
"0"));
+    }
+    List<String> categories = new ArrayList<>(byCategory.keySet());
+    categories.sort(Comparator.comparing(categoryOrder::get));
+
+    for (String category : categories) {
+      MenuItem categoryItem = new MenuItem(menu, SWT.CASCADE);
+      categoryItem.setText(category);
+      Menu subMenu = new Menu(menu);
+      categoryItem.setMenu(subMenu);
+      for (GuiAction action : byCategory.get(category)) {
+        addActionMenuItem(subMenu, action, parent);
+      }
+    }
+
+    if (!uncategorized.isEmpty()) {
+      if (!categories.isEmpty()) {
+        new MenuItem(menu, SWT.SEPARATOR);
+      }
+      for (GuiAction action : uncategorized) {
+        addActionMenuItem(menu, action, parent);
+      }
+    }
+
+    menu.setLocation(displayX, displayY);
+
+    // Dispose the menu once it is hidden to avoid leaking widgets.
+    //
+    menu.addListener(SWT.Hide, event -> 
menu.getDisplay().asyncExec(menu::dispose));
+
+    menu.setVisible(true);
+    return true;
+  }
+
+  /** Adds a single push menu item for a {@link GuiAction} (icon, label and 
its action lambda). */
+  public static void addActionMenuItem(Menu menu, GuiAction action, Shell 
shell) {
+    MenuItem menuItem = new MenuItem(menu, SWT.PUSH);
+    menuItem.setText(Const.NVL(action.getName(), action.getId()));
+    if (StringUtils.isNotEmpty(action.getTooltip())) {
+      menuItem.setToolTipText(action.getTooltip());
+    }
+
+    // Load the action image (SVG) when there is one.
+    //
+    if (StringUtils.isNotEmpty(action.getImage())) {
+      try {
+        ClassLoader classLoader = action.getClassLoader();
+        if (classLoader == null) {
+          classLoader = GuiContextMenu.class.getClassLoader();
+        }
+        Image image =
+            GuiResource.getInstance()
+                .getImage(
+                    action.getImage(),
+                    classLoader,
+                    ConstUi.SMALL_ICON_SIZE,
+                    ConstUi.SMALL_ICON_SIZE);
+        menuItem.setImage(image);
+      } catch (Exception e) {
+        // Ignore image loading errors, the menu item text is enough.
+      }
+    }
+
+    menuItem.addListener(
+        SWT.Selection,
+        event -> {
+          boolean shiftClicked = (event.stateMask & SWT.SHIFT) != 0;
+          boolean ctrlClicked = (event.stateMask & SWT.CONTROL) != 0;
+          // Defer execution until the menu is fully closed.
+          //
+          shell
+              .getDisplay()
+              .asyncExec(
+                  () -> {
+                    try {
+                      IGuiActionLambda<?> actionLambda = 
action.getActionLambda();
+                      actionLambda.executeAction(shiftClicked, ctrlClicked);
+                    } catch (Exception e) {
+                      if (!SwtErrorHandler.handleException(e)) {
+                        new ErrorDialog(shell, "Error", "An error occurred 
executing action", e);
+                      }
+                    }
+                  });
+        });
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiContextDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiContextDelegate.java
index 0fb3189590..11ed598b1b 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiContextDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiContextDelegate.java
@@ -20,24 +20,19 @@ package org.apache.hop.ui.hopgui.delegates;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
-import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.gui.Point;
-import org.apache.hop.core.gui.plugin.IGuiActionLambda;
 import org.apache.hop.core.gui.plugin.action.GuiAction;
 import org.apache.hop.core.gui.plugin.action.GuiActionType;
 import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.ui.core.ConstUi;
-import org.apache.hop.ui.core.dialog.ErrorDialog;
-import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.context.GuiContextMenu;
 import org.apache.hop.ui.hopgui.context.GuiContextUtil;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.file.HopFileTypeRegistry;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.perspective.metadata.MetadataPerspective;
 import org.eclipse.swt.SWT;
-import org.eclipse.swt.graphics.Image;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.widgets.Menu;
 import org.eclipse.swt.widgets.MenuItem;
@@ -86,7 +81,7 @@ public class HopGuiContextDelegate {
           Comparator.comparing((GuiAction a) -> 
Const.NVL(a.getCategoryOrder(), "9999"))
               .thenComparing(a -> Const.NVL(a.getName(), a.getId())));
       for (GuiAction action : fileActions) {
-        addActionMenuItem(menu, action, shell);
+        GuiContextMenu.addActionMenuItem(menu, action, shell);
       }
     }
 
@@ -137,53 +132,6 @@ public class HopGuiContextDelegate {
     menu.setVisible(true);
   }
 
-  /** Adds a single push menu item for a {@link GuiAction} (icon, label and 
its action lambda). */
-  private void addActionMenuItem(Menu menu, GuiAction action, Shell shell) {
-    MenuItem menuItem = new MenuItem(menu, SWT.PUSH);
-    menuItem.setText(Const.NVL(action.getName(), action.getId()));
-
-    // Load the action image (SVG) when there is one.
-    //
-    if (StringUtils.isNotEmpty(action.getImage())) {
-      try {
-        ClassLoader classLoader = action.getClassLoader();
-        if (classLoader == null) {
-          classLoader = getClass().getClassLoader();
-        }
-        Image image =
-            GuiResource.getInstance()
-                .getImage(
-                    action.getImage(),
-                    classLoader,
-                    ConstUi.SMALL_ICON_SIZE,
-                    ConstUi.SMALL_ICON_SIZE);
-        menuItem.setImage(image);
-      } catch (Exception e) {
-        // Ignore image loading errors, the menu item text is enough.
-      }
-    }
-
-    menuItem.addListener(
-        SWT.Selection,
-        event -> {
-          boolean shiftClicked = (event.stateMask & SWT.SHIFT) != 0;
-          boolean ctrlClicked = (event.stateMask & SWT.CONTROL) != 0;
-          // Defer execution until the menu is fully closed.
-          //
-          hopGui
-              .getDisplay()
-              .asyncExec(
-                  () -> {
-                    try {
-                      IGuiActionLambda<?> actionLambda = 
action.getActionLambda();
-                      actionLambda.executeAction(shiftClicked, ctrlClicked);
-                    } catch (Exception e) {
-                      new ErrorDialog(shell, "Error", "An error occurred 
executing action", e);
-                    }
-                  });
-        });
-  }
-
   /** Edit a metadata object... */
   public void fileMetadataEdit() {
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 7d32f19b6e..5bafba3bb1 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -171,6 +171,7 @@ import org.apache.hop.ui.hopgui.TestIdFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.context.GuiContextMenu;
 import org.apache.hop.ui.hopgui.context.GuiContextUtil;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.delegates.HopGuiServerDelegate;
@@ -763,6 +764,11 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     setToolTip(graphX, graphY, screenX, screenY);
   }
 
+  @Override
+  public void handleWebCanvasHoverEnd() {
+    hideHoverToolTip();
+  }
+
   @Override
   public void mouseDoubleClick(MouseEvent event) {
 
@@ -1699,6 +1705,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       toolTip.setAutoHide(true);
       toolTip.setText(Const.CR + "  Selection cleared " + Const.CR);
       showToolTip(new org.eclipse.swt.graphics.Point(e.x, e.y));
+      toolTip.hideAfter(TRANSIENT_TOOLTIP_MILLIS);
 
       return;
     }
@@ -1764,11 +1771,16 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     this.openedContextDialog = true;
     this.hideToolTips();
 
-    // Show the context dialog
+    // Show the context dialog, or a pop-up menu when the user prefers those
     //
-    avoidContextDialog =
-        GuiContextUtil.getInstance()
-            .handleActionSelection(parent, message, new Point(p.x, p.y), 
contextHandler);
+    if (useContextMenu(target.type == SingleClickType.Pipeline)) {
+      GuiContextMenu.show(parent, contextHandler, p.x, p.y);
+      avoidContextDialog = false;
+    } else {
+      avoidContextDialog =
+          GuiContextUtil.getInstance()
+              .handleActionSelection(parent, message, new Point(p.x, p.y), 
contextHandler);
+    }
 
     this.openedContextDialog = false;
   }
@@ -2449,7 +2461,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
     // disable the tooltip
     //
-    toolTip.setVisible(false);
+    hideHoverToolTip();
 
     // First, check for operations that have been started, such as move 
selection, dragging the
     // view, creating a hop or resizing a note.
@@ -4773,6 +4785,10 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           }
           break;
 
+        case TRANSFORM_NAME:
+          // A single click on the name opens the transform dialog: say so.
+          tip.append(BaseMessages.getString(PKG, 
"HopGuiPipelineGraph.TransformName.Tooltip"));
+          break;
         case TRANSFORM_INFO_ICON, TRANSFORM_ICON:
           TransformMeta iconTransformMeta = (TransformMeta) 
areaOwner.getOwner();
 
@@ -4876,7 +4892,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     }
 
     if (newTip == null) {
-      toolTip.setVisible(false);
+      hideHoverToolTip();
       if (hi != null) { // We clicked on a HOP!
 
         // Set the tooltip for the hop:
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
index 878fa7c348..ab8324b479 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
@@ -44,6 +44,7 @@ import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.file.IGraphSnapAlignDistribute;
 import org.apache.hop.ui.hopgui.file.IHopFileType;
 import org.apache.hop.ui.hopgui.file.delegates.HopGuiNoteLinkSupport;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
 import org.apache.hop.ui.hopgui.perspective.execution.DragViewZoomBase;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.widgets.Composite;
@@ -70,6 +71,13 @@ public abstract class HopGuiAbstractGraph extends 
DragViewZoomBase
   protected Rectangle resizeArea;
   protected Resize resize;
   protected HopToolTip toolTip;
+
+  /**
+   * How long a notice such as "Selection cleared" stays up. On the desktop 
the next mouse move
+   * hides it anyway; Hop Web does not forward mouse moves, so the timer is 
what takes it down.
+   */
+  protected static final int TRANSIENT_TOOLTIP_MILLIS = 1500;
+
   protected String mouseOverName;
 
   /** Hovered Markdown note hyperlink (for underline emphasis and hand 
cursor). */
@@ -148,6 +156,33 @@ public abstract class HopGuiAbstractGraph extends 
DragViewZoomBase
     redraw();
   }
 
+  /**
+   * "Use menus instead of the context dialog": the actions of a transform, 
action, hop or note are
+   * shown in a pop-up menu. A click on the empty canvas keeps the context 
dialog while the design
+   * palette is hidden, because that is where new transforms and actions are 
searched for; with the
+   * palette shown the empty canvas gets a menu too.
+   *
+   * @param emptyCanvas true when the click was on the empty canvas 
(pipeline/workflow context)
+   * @return true when a pop-up menu should be shown instead of the context 
dialog
+   */
+  protected boolean useContextMenu(boolean emptyCanvas) {
+    if (!PropsUi.getInstance().useMenusInsteadOfContextDialog()) {
+      return false;
+    }
+    return !emptyCanvas || GraphPalette.isVisible();
+  }
+
+  /**
+   * The hide for a mouse move: takes down the tooltip of whatever was under 
the pointer, but not a
+   * notice such as "Selection cleared". That one is not tied to the pointer 
and stays until its
+   * timer fires or something else is shown or hidden.
+   */
+  protected void hideHoverToolTip() {
+    if (!toolTip.isNotice()) {
+      toolTip.setVisible(false);
+    }
+  }
+
   protected void showToolTip(org.eclipse.swt.graphics.Point location) {
     org.eclipse.swt.graphics.Point p = canvas.toDisplay(location);
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index c979172e4b..d34adc8855 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -129,6 +129,7 @@ import org.apache.hop.ui.hopgui.TestIdFacade;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
 import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
 import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.context.GuiContextMenu;
 import org.apache.hop.ui.hopgui.context.GuiContextUtil;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.dialog.NotePadDialog;
@@ -652,6 +653,11 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     setToolTip(graphX, graphY, screenX, screenY);
   }
 
+  @Override
+  public void handleWebCanvasHoverEnd() {
+    hideHoverToolTip();
+  }
+
   protected void hideToolTips() {
     toolTip.setVisible(false);
   }
@@ -1416,6 +1422,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       toolTip.setVisible(false);
       toolTip.setText(Const.CR + "  Selection cleared " + Const.CR);
       showToolTip(new org.eclipse.swt.graphics.Point(event.x, event.y));
+      toolTip.hideAfter(TRANSIENT_TOOLTIP_MILLIS);
 
       return;
     }
@@ -1481,11 +1488,16 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     this.openedContextDialog = true;
     this.hideToolTips();
 
-    // Show the context dialog
+    // Show the context dialog, or a pop-up menu when the user prefers those
     //
-    ignoreNextClick =
-        GuiContextUtil.getInstance()
-            .handleActionSelection(parent, message, new Point(p.x, p.y), 
contextHandler);
+    if (useContextMenu(target.type == SingleClickType.Workflow)) {
+      GuiContextMenu.show(parent, contextHandler, p.x, p.y);
+      ignoreNextClick = false;
+    } else {
+      ignoreNextClick =
+          GuiContextUtil.getInstance()
+              .handleActionSelection(parent, message, new Point(p.x, p.y), 
contextHandler);
+    }
 
     this.openedContextDialog = false;
   }
@@ -2142,7 +2154,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
     // disable the tooltip
     //
-    hideToolTips();
+    hideHoverToolTip();
 
     // First, check for operations that have been started, such as move 
selection, dragging the
     // view, creating a hop or resizing a note.
@@ -4081,6 +4093,10 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
             tip.append(actionMetaInfo.getDescription());
           }
           break;
+        case ACTION_NAME:
+          // A single click on the name opens the action dialog: say so.
+          tip.append(BaseMessages.getString(PKG, 
"WorkflowGraph.ActionName.Tooltip"));
+          break;
         default:
           // For plugins...
           //
@@ -4133,7 +4149,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
 
     if (Utils.isEmpty(tip)) {
-      toolTip.setVisible(false);
+      hideHoverToolTip();
     } else {
       if (!tip.toString().equalsIgnoreCase(getToolTipText())) {
         toolTip.setText(tip.toString());
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index f6a99464b6..30c51b2f8a 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -109,6 +109,7 @@ public class ConfigGuiOptionsTab {
   private Button wHideViewport;
   private Button wUseDoubleClick;
   private Button wUseRightClickForContextDialog;
+  private Button wUseMenusInsteadOfContextDialog;
   private Button wDialogsOnAnyScreen;
   private Button wDrawBorderAroundCanvasNames;
   private Button wEnableInfiniteMove;
@@ -207,6 +208,7 @@ public class ConfigGuiOptionsTab {
       wHideViewport.setSelection(!props.isHideViewportEnabled()); // Inverted 
logic
       wUseDoubleClick.setSelection(props.useDoubleClick());
       
wUseRightClickForContextDialog.setSelection(props.useRightClickForContextDialog());
+      
wUseMenusInsteadOfContextDialog.setSelection(props.useMenusInsteadOfContextDialog());
       if (wDialogsOnAnyScreen != null) {
         wDialogsOnAnyScreen.setSelection(props.isDialogsOnAnyScreenEnabled());
       }
@@ -740,6 +742,17 @@ public class ConfigGuiOptionsTab {
             margin);
     lastCanvasControl = wUseRightClickForContextDialog;
 
+    // Use menus instead of the context dialog
+    wUseMenusInsteadOfContextDialog =
+        createCheckbox(
+            canvasContent,
+            "EnterOptionsDialog.UseMenusInsteadOfContextDialog.Label",
+            "EnterOptionsDialog.UseMenusInsteadOfContextDialog.ToolTip",
+            props.useMenusInsteadOfContextDialog(),
+            lastCanvasControl,
+            margin);
+    lastCanvasControl = wUseMenusInsteadOfContextDialog;
+
     // Draw border around canvas names
     wDrawBorderAroundCanvasNames =
         createCheckbox(
@@ -1369,6 +1382,7 @@ public class ConfigGuiOptionsTab {
         !wHideViewport.getSelection()); // Inverted: checkbox is "show", 
property is "hide"
     props.setUseDoubleClickOnCanvas(wUseDoubleClick.getSelection());
     
props.setUseRightClickForContextDialog(wUseRightClickForContextDialog.getSelection());
+    
props.setUseMenusInsteadOfContextDialog(wUseMenusInsteadOfContextDialog.getSelection());
     if (wDialogsOnAnyScreen != null) {
       props.setDialogsOnAnyScreenEnabled(wDialogsOnAnyScreen.getSelection());
     }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java
index 9995dd31d3..d720238996 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/IWebCanvasGraph.java
@@ -46,4 +46,10 @@ public interface IWebCanvasGraph {
    * @param screenY screen Y relative to the canvas widget
    */
   void handleWebCanvasHover(int graphX, int graphY, int screenX, int screenY);
+
+  /**
+   * The pointer left the hovered area (or the canvas altogether): whatever 
the hover showed, such
+   * as a tooltip, goes away.
+   */
+  void handleWebCanvasHoverEnd();
 }
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index 836ec23316..ca24e62a4e 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -226,6 +226,8 @@ EnterOptionsDialog.UseDatabaseCache.Label=Use database cache
 EnterOptionsDialog.UseDoubleClickOnCanvas.Label=Use double click on canvas
 EnterOptionsDialog.UseRightClickForContextDialog.Label=Use right click for the 
context dialog
 EnterOptionsDialog.UseRightClickForContextDialog.ToolTip=A right click opens 
the context dialog of whatever is under the pointer and a left click only 
selects. Off: a left click opens the context dialog.
+EnterOptionsDialog.UseMenusInsteadOfContextDialog.Label=Use menus instead of 
the context dialog
+EnterOptionsDialog.UseMenusInsteadOfContextDialog.ToolTip=Transforms, actions, 
hops and notes show their actions in a pop-up menu with a submenu per category. 
A click on the empty canvas keeps the context dialog while the design palette 
is hidden, since that is where new transforms and actions are searched for.
 EnterOptionsDialog.DialogsOnAnyScreen.Label=Allow dialogs to open on any screen
 EnterOptionsDialog.DialogsOnAnyScreen.ToolTip=Off: transform, action and 
metadata dialogs follow the Hop window and stay above it. On: they open as 
windows of their own that you can move to another screen.
 EnterOptionsDialog.UseGlobalFileBookmarks.Label=Use global bookmarks in the 
file dialog
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
index 4ba3ed7335..dc6fecf96b 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
@@ -119,6 +119,7 @@ PipelineGraph.RunningFile.Dialog.Message=Workflow ''{0}'' 
is still running.  Do
 WorkflowGraph.DeprecatedEntry.Tooltip.Message1={0} will be removed in an 
upcoming
 WorkflowGraph.DeprecatedEntry.Tooltip.Message2=release or may not be working 
at this time.
 WorkflowGraph.DeprecatedEntry.Tooltip.Message3=We suggest\nusing {0} instead 
to ensure compatibility\nmoving forward.
+WorkflowGraph.ActionName.Tooltip=Click to edit this action
 WorkflowGraph.DeprecatedEntry.Tooltip.Title=Deprecated
 WorkflowGraph.Dialog.EditDescription.Message=Edit description
 WorkflowGraph.Dialog.EditDescription.Title=Description
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 4e4e8504e4..bc02ac1e94 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -206,6 +206,7 @@ PipelineGraph.DeprecatedTransform.Tooltip.Message2=release 
or may not be working
 PipelineGraph.DeprecatedTransform.Tooltip.Message3=We suggest\nusing {0} 
instead to ensure compatibility\nmoving forward.
 HopGuiPipelineGraph.PipelineSource.Keyword=pipeline source
 HopGuiPipelineGraph.PipelineSource.TooltipSuffix=Can start without incoming 
hops (pipeline source).
+HopGuiPipelineGraph.TransformName.Tooltip=Click to edit this transform
 PipelineGraph.DeprecatedTransform.Tooltip.Title=This is a deprecated transform 
and will be removed.
 PipelineGraph.Dialog.CouldntFindFields.Message=I couldn''t find any fields\!
 PipelineGraph.Dialog.CouldntFindFields.Title=Fields info
diff --git a/ui/src/test/java/org/apache/hop/ui/core/gui/HopToolTipTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/gui/HopToolTipTest.java
new file mode 100644
index 0000000000..d7715bbac0
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/gui/HopToolTipTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.core.gui;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.hop.core.Const;
+import org.junit.jupiter.api.Test;
+
+class HopToolTipTest {
+
+  @Test
+  void cleanTextDropsAsciiRulesAndSurroundingBlankLines() {
+    String text =
+        Const.CR
+            + "This is a deprecated transform"
+            + Const.CR
+            + "-----------------------------------"
+            + Const.CR
+            + "Use Lookup instead.  "
+            + Const.CR;
+    assertEquals(
+        "This is a deprecated transform" + Const.CR + "Use Lookup instead.",
+        HopToolTip.cleanText(text));
+  }
+
+  @Test
+  void cleanTextKeepsInnerBlankLinesAndDashesInProse() {
+    String text = "Hop info" + Const.CR + Const.CR + "Read CSV -> Filter - 
stage 2";
+    assertEquals(text, HopToolTip.cleanText(text));
+    assertEquals("", HopToolTip.cleanText(null));
+    assertEquals("Selection cleared", HopToolTip.cleanText("\n  Selection 
cleared \n"));
+  }
+
+  @Test
+  void roundedRectangleWithoutRadiusIsThePlainRectangle() {
+    assertArrayEquals(
+        new int[] {0, 0, 40, 0, 40, 20, 0, 20}, 
RoundedShellRegion.roundedRectangle(40, 20, 0));
+  }
+
+  @Test
+  void roundedRectangleStaysInsideTheBoundsAndTouchesEveryEdge() {
+    int width = 120;
+    int height = 30;
+    int[] points = RoundedShellRegion.roundedRectangle(width, height, 6);
+    int minX = Integer.MAX_VALUE;
+    int maxX = Integer.MIN_VALUE;
+    int minY = Integer.MAX_VALUE;
+    int maxY = Integer.MIN_VALUE;
+    for (int i = 0; i < points.length; i += 2) {
+      minX = Math.min(minX, points[i]);
+      maxX = Math.max(maxX, points[i]);
+      minY = Math.min(minY, points[i + 1]);
+      maxY = Math.max(maxY, points[i + 1]);
+    }
+    assertEquals(0, minX);
+    assertEquals(width, maxX);
+    assertEquals(0, minY);
+    assertEquals(height, maxY);
+    // the corners themselves are cut off
+    assertEquals(false, containsPoint(points, 0, 0));
+    assertEquals(false, containsPoint(points, width, height));
+  }
+
+  private static boolean containsPoint(int[] points, int x, int y) {
+    for (int i = 0; i < points.length; i += 2) {
+      if (points[i] == x && points[i + 1] == y) {
+        return true;
+      }
+    }
+    return false;
+  }
+}

Reply via email to