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;
+ }
+}