This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 4a53f2526c Issue #8326 : Render ContextDialog content as vector SVG in
Hop Web (#8352)
4a53f2526c is described below
commit 4a53f2526c62ddae043611de6c288e34cd9ea282
Author: Matt Casters <[email protected]>
AuthorDate: Mon Sep 14 21:06:26 2026 +0200
Issue #8326 : Render ContextDialog content as vector SVG in Hop Web (#8352)
* Issue #8326 : Render ContextDialog content as vector SVG in Hop Web
- Render ContextDialog category headers and action items into crisp vector
SVG in Hop Web instead of raster images
- Provide client-side SVG overlay with hover highlight and tooltip updates
via context-dialog-svg.js
- Ensure preserveAspectRatio="none" and proper content height
synchronization across collapse and expand transitions
- Guard against re-entrant canvas paint during height update in
ContextDialogSvgRendererHandler
- Support both light and dark mode themes in SVG generation
* Issue #8326 : Enable ContextDialog toolbar checkboxes in Hop Web
---
.../ui/core/dialog/ContextDialogSvgFacadeImpl.java | 44 +++
.../dialog/ContextDialogSvgRendererHandler.java | 137 +++++++++
.../main/java/org/apache/hop/ui/hopgui/HopWeb.java | 1 +
.../org/apache/hop/ui/hopgui/HopWebEntryPoint.java | 1 +
.../ui/hopgui/canvas/AreaOwnerJsonSerializer.java | 21 +-
.../org/apache/hop/ui/hopgui/context-dialog-svg.js | 314 ++++++++++++++++++++
.../hop/ui/hopgui/CanvasOverlayAttachTest.java | 11 +
.../hopgui/canvas/AreaOwnerJsonSerializerTest.java | 93 ++++++
.../ui/core/dialog/ContextDialogSvgFacadeImpl.java | 38 +++
.../apache/hop/ui/core/dialog/ContextDialog.java | 145 +++++++---
.../hop/ui/core/dialog/ContextDialogSvgFacade.java | 58 ++++
.../core/dialog/ContextDialogSvgRenderResult.java | 24 ++
.../ui/core/dialog/ContextDialogSvgRenderer.java | 318 +++++++++++++++++++++
.../apache/hop/ui/core/gui/GuiToolbarWidgets.java | 4 +
.../core/dialog/ContextDialogSvgRendererTest.java | 229 +++++++++++++++
15 files changed, 1404 insertions(+), 34 deletions(-)
diff --git
a/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacadeImpl.java
b/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacadeImpl.java
new file mode 100644
index 0000000000..53d1e568a6
--- /dev/null
+++
b/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacadeImpl.java
@@ -0,0 +1,44 @@
+/*
+ * 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.dialog;
+
+import org.eclipse.swt.widgets.Canvas;
+
+/** Hop Web (RAP) implementation of ContextDialogSvgFacade. */
+public class ContextDialogSvgFacadeImpl extends ContextDialogSvgFacade {
+
+ @Override
+ boolean isSupportedInternal() {
+ return true;
+ }
+
+ @Override
+ void registerInternal(Canvas canvas, ContextDialog dialog) {
+ ContextDialogSvgRendererHandler.register(canvas, dialog);
+ }
+
+ @Override
+ void unregisterInternal(Canvas canvas) {
+ ContextDialogSvgRendererHandler.unregister(canvas);
+ }
+
+ @Override
+ void renderAndPublishInternal(Canvas canvas, ContextDialog dialog) {
+ ContextDialogSvgRendererHandler.renderAndPublish(canvas, dialog);
+ }
+}
diff --git
a/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
b/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
new file mode 100644
index 0000000000..2bfb22c2ab
--- /dev/null
+++
b/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
@@ -0,0 +1,137 @@
+/*
+ * 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.dialog;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.hopgui.canvas.AreaOwnerJsonSerializer;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.remote.Connection;
+import org.eclipse.rap.rwt.remote.RemoteObject;
+import org.eclipse.rap.rwt.widgets.WidgetUtil;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Canvas;
+
+/** Manages the client-side SVG renderer remote object for Hop Web
ContextDialog. */
+public final class ContextDialogSvgRendererHandler {
+
+ private static final Map<String, RemoteObject> remoteObjects = new
ConcurrentHashMap<>();
+ private static final Set<String> renderingCanvases =
ConcurrentHashMap.newKeySet();
+
+ private ContextDialogSvgRendererHandler() {}
+
+ public static void register(Canvas canvas, ContextDialog dialog) {
+ if (canvas == null || canvas.isDisposed()) {
+ return;
+ }
+ try {
+ String canvasId = WidgetUtil.getId(canvas);
+ Connection connection = RWT.getUISession().getConnection();
+ RemoteObject remoteObject =
connection.createRemoteObject("hop.ContextDialogSvgRenderer");
+ remoteObject.set("canvasId", canvasId);
+ if (dialog.getTooltipLabel() != null &&
!dialog.getTooltipLabel().isDisposed()) {
+ remoteObject.set("tooltipId",
WidgetUtil.getId(dialog.getTooltipLabel()));
+ }
+ remoteObjects.put(canvasId, remoteObject);
+ } catch (Exception e) {
+ LogChannel.UI.logError("Failed to register ContextDialog SVG renderer
RemoteObject", e);
+ }
+ }
+
+ public static void unregister(Canvas canvas) {
+ if (canvas == null) {
+ return;
+ }
+ try {
+ String canvasId = WidgetUtil.getId(canvas);
+ renderingCanvases.remove(canvasId);
+ RemoteObject remoteObject = remoteObjects.remove(canvasId);
+ if (remoteObject != null) {
+ remoteObject.destroy();
+ }
+ } catch (Exception e) {
+ LogChannel.UI.logDebug(
+ "Failed to unregister ContextDialog SVG renderer RemoteObject: " +
e.getMessage());
+ }
+ }
+
+ public static void renderAndPublish(Canvas canvas, ContextDialog dialog) {
+ if (canvas == null || canvas.isDisposed() || dialog == null) {
+ return;
+ }
+ String canvasId = WidgetUtil.getId(canvas);
+ if (!renderingCanvases.add(canvasId)) {
+ return;
+ }
+ try {
+ RemoteObject remoteObject = remoteObjects.get(canvasId);
+ if (remoteObject == null) {
+ register(canvas, dialog);
+ remoteObject = remoteObjects.get(canvasId);
+ }
+ if (remoteObject == null) {
+ return;
+ }
+
+ int areaWidth = 800;
+ int areaHeight = 600;
+ if (dialog.getScrolledComposite() != null &&
!dialog.getScrolledComposite().isDisposed()) {
+ Rectangle area = dialog.getScrolledComposite().getClientArea();
+ if (area.width > 0) {
+ areaWidth = area.width;
+ }
+ if (area.height > 0) {
+ areaHeight = area.height;
+ }
+ }
+
+ ContextDialogSvgRenderResult result =
+ ContextDialogSvgRenderer.render(dialog, areaWidth, areaHeight);
+
+ dialog.setAreaOwners(result.areaOwners());
+ dialog.updateContentHeight(result.totalContentHeight());
+
+ if (dialog.getScrolledComposite() != null &&
!dialog.getScrolledComposite().isDisposed()) {
+ Rectangle newArea = dialog.getScrolledComposite().getClientArea();
+ if (newArea.width > 0 && newArea.width != areaWidth) {
+ areaWidth = newArea.width;
+ result = ContextDialogSvgRenderer.render(dialog, areaWidth,
areaHeight);
+ dialog.setAreaOwners(result.areaOwners());
+ dialog.updateContentHeight(result.totalContentHeight());
+ }
+ }
+
+ remoteObject.set("contentHeight", result.totalContentHeight());
+ remoteObject.set("svg", result.svg());
+ remoteObject.set("areas",
AreaOwnerJsonSerializer.toJsonArray(result.areaOwners()));
+ if (dialog.getSelectedItem() != null &&
dialog.getSelectedItem().getAction() != null) {
+ remoteObject.set(
+ "selectedTooltip",
Const.NVL(dialog.getSelectedItem().getAction().getTooltip(), ""));
+ } else {
+ remoteObject.set("selectedTooltip", "");
+ }
+ } catch (Exception e) {
+ LogChannel.UI.logError("Failed to render and publish ContextDialog SVG",
e);
+ } finally {
+ renderingCanvases.remove(canvasId);
+ }
+ }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index 5089dc5578..002801248a 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -168,6 +168,7 @@ public class HopWeb implements ApplicationConfiguration {
"org/apache/hop/ui/hopgui/clipboard.js",
"org/apache/hop/ui/hopgui/canvas-zoom.js",
"org/apache/hop/ui/hopgui/canvas-svg.js",
+ "org/apache/hop/ui/hopgui/context-dialog-svg.js",
"org/apache/hop/ui/hopgui/gc-pending-images.js",
"org/apache/hop/ui/hopgui/monaco-editor.js",
"org/apache/hop/ui/hopgui/mac-command-keys.js")
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index aa799ab010..ac7d8900b0 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -170,6 +170,7 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
String jsLocation = resourceManager.getLocation("js/canvas-zoom.js");
jsLoader.require(jsLocation);
jsLoader.require(resourceManager.getLocation("js/canvas-svg.js"));
+ jsLoader.require(resourceManager.getLocation("js/context-dialog-svg.js"));
// RAP's GC leaves image onload handlers alive after dispose; see the
script.
jsLoader.require(resourceManager.getLocation("js/gc-pending-images.js"));
jsLoader.require(resourceManager.getLocation("js/monaco-editor.js"));
diff --git
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializer.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializer.java
index ab31726204..29cfd3c5a5 100644
---
a/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializer.java
+++
b/rap/src/main/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializer.java
@@ -18,6 +18,7 @@
package org.apache.hop.ui.hopgui.canvas;
import java.util.List;
+import org.apache.hop.core.Const;
import org.apache.hop.core.NotePadMeta;
import org.apache.hop.core.gui.AreaOwner;
import org.apache.hop.core.gui.Rectangle;
@@ -26,6 +27,7 @@ import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.row.RowBuffer;
import org.apache.hop.pipeline.PipelineHopMeta;
import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.dialog.ContextDialog;
import org.apache.hop.workflow.WorkflowHopMeta;
import org.apache.hop.workflow.action.ActionMeta;
import org.eclipse.rap.json.JsonArray;
@@ -55,7 +57,10 @@ public final class AreaOwnerJsonSerializer {
json.add("y", area.y);
json.add("width", area.width);
json.add("height", area.height);
- json.add("hover", areaOwner.getAreaType().isSupportHover());
+ boolean hover =
+ areaOwner.getAreaType().isSupportHover()
+ || (areaOwner.getOwner() instanceof ContextDialog.Item);
+ json.add("hover", hover);
json.add(
"owner", ownerToJson(areaOwner.getOwner(), areaOwner.getParent(),
areaOwner.getAreaType()));
return json;
@@ -124,6 +129,20 @@ public final class AreaOwnerJsonSerializer {
}
return json;
}
+ if (owner instanceof ContextDialog.Item item) {
+ json.add("kind", "contextItem");
+ json.add("name", item.getText());
+ json.add("actionId", item.getAction() != null ? item.getAction().getId()
: "");
+ json.add(
+ "tooltip", item.getAction() != null ?
Const.NVL(item.getAction().getTooltip(), "") : "");
+ return json;
+ }
+ if (owner instanceof ContextDialog.CategoryAndOrder category) {
+ json.add("kind", "contextCategory");
+ json.add("category", category.getCategory());
+ json.add("collapsed", category.isCollapsed());
+ return json;
+ }
LogChannel.UI.logDebug(
"Unknown AreaOwner owner type for web canvas: "
+ owner.getClass().getName()
diff --git
a/rap/src/main/resources/org/apache/hop/ui/hopgui/context-dialog-svg.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/context-dialog-svg.js
new file mode 100644
index 0000000000..3ba3c778aa
--- /dev/null
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/context-dialog-svg.js
@@ -0,0 +1,314 @@
+/*
+ * 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.
+ */
+
+//# sourceURL=context-dialog-svg.js
+(function () {
+ "use strict";
+
+ if (!window.hop) {
+ window.hop = {};
+ }
+
+ function getWidgetDomElement(widgetId) {
+ if (!widgetId) {
+ return null;
+ }
+ try {
+ if (typeof rap !== "undefined" && typeof rap.getObject ===
"function") {
+ var proxy = rap.getObject(widgetId);
+ if (proxy && proxy.$el) {
+ var queried = proxy.$el.get ? proxy.$el.get(0) :
(proxy.$el[0] || proxy.$el);
+ if (queried && queried.tagName) {
+ return queried;
+ }
+ }
+ }
+ if (typeof rwt !== "undefined" && rwt.remote &&
rwt.remote.ObjectRegistry) {
+ var nativeWidget =
rwt.remote.ObjectRegistry.getObject(widgetId);
+ if (nativeWidget) {
+ if (typeof nativeWidget.getElement === "function") {
+ var element = nativeWidget.getElement();
+ if (element) {
+ return element;
+ }
+ }
+ if (typeof nativeWidget._getTargetNode === "function") {
+ var target = nativeWidget._getTargetNode();
+ if (target) {
+ return target;
+ }
+ }
+ if (nativeWidget._element) {
+ return nativeWidget._element;
+ }
+ }
+ }
+ } catch (ignored) {}
+ return document.getElementById(widgetId);
+ }
+
+ function findCanvasElement(canvasId) {
+ var el = getWidgetDomElement(canvasId);
+ if (!el) {
+ return null;
+ }
+ if (el.tagName === "CANVAS") {
+ return el;
+ }
+ return el.querySelector("canvas") || el;
+ }
+
+ hop.ContextDialogSvgRenderer = function (properties) {
+ this._canvasId = properties.canvasId;
+ this._tooltipId = properties.tooltipId;
+ this._svg = null;
+ this._areas = [];
+ this._contentHeight = 0;
+ this._selectedTooltip = "";
+ this._overlay = null;
+ this._hoverBox = null;
+ this._canvas = null;
+ this._mousemoveHandler = null;
+ this._mouseleaveHandler = null;
+ this._findTimer = null;
+ };
+
+ hop.ContextDialogSvgRenderer.prototype = {
+ destroy: function () {
+ if (this._findTimer) {
+ clearTimeout(this._findTimer);
+ this._findTimer = null;
+ }
+ if (this._canvas && this._mousemoveHandler) {
+ this._canvas.removeEventListener("mousemove",
this._mousemoveHandler);
+ }
+ if (this._canvas && this._mouseleaveHandler) {
+ this._canvas.removeEventListener("mouseleave",
this._mouseleaveHandler);
+ }
+ if (this._overlay && this._overlay.parentNode) {
+ this._overlay.parentNode.removeChild(this._overlay);
+ }
+ this._overlay = null;
+ this._hoverBox = null;
+ this._canvas = null;
+ },
+
+ setCanvasId: function (properties) {
+ this._canvasId = properties.value;
+ this._attach();
+ },
+
+ setTooltipId: function (properties) {
+ this._tooltipId = properties.value;
+ },
+
+ setSelectedTooltip: function (properties) {
+ this._selectedTooltip = properties.value || "";
+ this._updateTooltipText(this._selectedTooltip);
+ },
+
+ setSvg: function (properties) {
+ this._svg = properties.value;
+ this._render();
+ },
+
+ setAreas: function (properties) {
+ this._areas = properties.value || [];
+ if (this._hoverBox) {
+ this._hoverBox.style.display = "none";
+ }
+ },
+
+ setContentHeight: function (properties) {
+ this._contentHeight = properties.value || 0;
+ if (this._overlay) {
+ this._overlay.style.height = this._contentHeight + "px";
+ var svgEl = this._overlay.querySelector("svg");
+ if (svgEl && this._contentHeight > 0) {
+ svgEl.setAttribute("height", String(this._contentHeight));
+ }
+ }
+ },
+
+ _attach: function () {
+ var self = this;
+ var attempts = 0;
+ var find = function () {
+ var target = findCanvasElement(self._canvasId);
+ if (target && target.parentNode) {
+ self._canvas = target;
+ self._initOverlay();
+ self._render();
+ } else if (attempts++ < 30) {
+ self._findTimer = setTimeout(find, 50);
+ }
+ };
+ find();
+ },
+
+ _initOverlay: function () {
+ if (this._overlay || !this._canvas) {
+ return;
+ }
+ var container = this._canvas;
+ if (container.tagName === "CANVAS" && container.parentElement) {
+ container = container.parentElement;
+ }
+ if (window.getComputedStyle(container).position === "static") {
+ container.style.position = "relative";
+ }
+
+ this._overlay = document.createElement("div");
+ this._overlay.setAttribute("data-hop-context-dialog-svg", "true");
+ this._overlay.style.position = "absolute";
+ this._overlay.style.left = "0px";
+ this._overlay.style.top = "0px";
+ this._overlay.style.width = "100%";
+ this._overlay.style.pointerEvents = "none";
+ this._overlay.style.zIndex = "10";
+
+ this._hoverBox = document.createElement("div");
+ this._hoverBox.style.position = "absolute";
+ this._hoverBox.style.display = "none";
+ this._hoverBox.style.pointerEvents = "none";
+ this._hoverBox.style.boxSizing = "border-box";
+ this._hoverBox.style.border = "2px solid #005da6";
+ this._hoverBox.style.borderRadius = "4px";
+ this._hoverBox.style.backgroundColor = "rgba(201, 232, 251, 0.25)";
+ this._overlay.appendChild(this._hoverBox);
+
+ container.appendChild(this._overlay);
+
+ var self = this;
+ this._mousemoveHandler = function (e) {
+ self._handleMouseMove(e);
+ };
+ this._mouseleaveHandler = function () {
+ self._handleMouseLeave();
+ };
+
+ this._canvas.addEventListener("mousemove", this._mousemoveHandler);
+ this._canvas.addEventListener("mouseleave",
this._mouseleaveHandler);
+ },
+
+ _render: function () {
+ if (!this._overlay || !this._svg) {
+ return;
+ }
+ var existingSvg = this._overlay.querySelector("svg");
+ if (existingSvg) {
+ this._overlay.removeChild(existingSvg);
+ }
+ var temp = document.createElement("div");
+ temp.innerHTML = this._svg;
+ var svgEl = temp.querySelector("svg");
+ if (svgEl) {
+ svgEl.style.display = "block";
+ svgEl.style.pointerEvents = "none";
+ if (this._contentHeight <= 0) {
+ var hAttr = parseInt(svgEl.getAttribute("height") || "0",
10);
+ if (hAttr > 0) {
+ this._contentHeight = hAttr;
+ }
+ }
+ if (this._contentHeight > 0) {
+ svgEl.setAttribute("height", String(this._contentHeight));
+ this._overlay.style.height = this._contentHeight + "px";
+ }
+ svgEl.setAttribute("preserveAspectRatio", "none");
+ this._overlay.insertBefore(svgEl, this._hoverBox);
+ }
+ },
+
+ _handleMouseMove: function (event) {
+ if (!this._areas || this._areas.length === 0 || !this._canvas) {
+ return;
+ }
+ var rect = this._canvas.getBoundingClientRect();
+ var x = event.clientX - rect.left;
+ var y = event.clientY - rect.top;
+
+ var hitItem = null;
+ for (var i = this._areas.length - 1; i >= 0; i--) {
+ var a = this._areas[i];
+ if (x >= a.x && x < a.x + a.width && y >= a.y && y < a.y +
a.height) {
+ if (a.owner && a.owner.kind === "contextItem") {
+ hitItem = a;
+ break;
+ }
+ }
+ }
+
+ if (hitItem && this._hoverBox) {
+ var m = 4;
+ this._hoverBox.style.left = (hitItem.x - m) + "px";
+ this._hoverBox.style.top = (hitItem.y - m) + "px";
+ this._hoverBox.style.width = (hitItem.width + 2 * m) + "px";
+ this._hoverBox.style.height = (hitItem.height + 2 * m) + "px";
+ this._hoverBox.style.display = "block";
+ this._updateTooltipText(hitItem.owner.tooltip || "");
+ } else {
+ this._handleMouseLeave();
+ }
+ },
+
+ _handleMouseLeave: function () {
+ if (this._hoverBox) {
+ this._hoverBox.style.display = "none";
+ }
+ this._updateTooltipText(this._selectedTooltip || "");
+ },
+
+ _updateTooltipText: function (text) {
+ if (!this._tooltipId) {
+ return;
+ }
+ var el = getWidgetDomElement(this._tooltipId);
+ if (el) {
+ el.textContent = text || "";
+ }
+ }
+ };
+
+ rap.registerTypeHandler("hop.ContextDialogSvgRenderer", {
+ factory: function (properties) {
+ return new hop.ContextDialogSvgRenderer(properties);
+ },
+ destructor: "destroy",
+ properties: ["canvasId", "tooltipId", "contentHeight", "svg", "areas",
"selectedTooltip"],
+ propertyHandler: {
+ canvasId: function (widget, value) {
+ widget.setCanvasId({ value: value });
+ },
+ tooltipId: function (widget, value) {
+ widget.setTooltipId({ value: value });
+ },
+ contentHeight: function (widget, value) {
+ widget.setContentHeight({ value: value });
+ },
+ svg: function (widget, value) {
+ widget.setSvg({ value: value });
+ },
+ areas: function (widget, value) {
+ widget.setAreas({ value: value });
+ },
+ selectedTooltip: function (widget, value) {
+ widget.setSelectedTooltip({ value: value });
+ }
+ }
+ });
+})();
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/CanvasOverlayAttachTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/CanvasOverlayAttachTest.java
index 8055092f92..c78ae9c836 100644
--- a/rap/src/test/java/org/apache/hop/ui/hopgui/CanvasOverlayAttachTest.java
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/CanvasOverlayAttachTest.java
@@ -54,6 +54,17 @@ class CanvasOverlayAttachTest {
assertTrue(js.contains("this._canvasId = properties.canvas"), js);
}
+ @Test
+ void contextDialogSvgJsLoadsAndDefinesHandler() throws IOException {
+ String js = readResource("org/apache/hop/ui/hopgui/context-dialog-svg.js");
+
+ assertNotNull(js);
+
assertTrue(js.contains("rap.registerTypeHandler(\"hop.ContextDialogSvgRenderer\""),
js);
+ assertTrue(js.contains("_handleMouseMove"), js);
+ assertTrue(js.contains("mousemove"), js);
+ assertTrue(js.contains("pointerEvents = \"none\""), js);
+ }
+
private static String readResource(String name) throws IOException {
InputStream in =
CanvasOverlayAttachTest.class.getClassLoader().getResourceAsStream(name);
assertNotNull(in, name);
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializerTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializerTest.java
new file mode 100644
index 0000000000..9179dd53ef
--- /dev/null
+++
b/rap/src/test/java/org/apache/hop/ui/hopgui/canvas/AreaOwnerJsonSerializerTest.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.hopgui.canvas;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.List;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.DPoint;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.ui.core.dialog.ContextDialog;
+import org.eclipse.rap.json.JsonArray;
+import org.eclipse.rap.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+class AreaOwnerJsonSerializerTest {
+
+ @Test
+ void testSerializeContextDialogItemAndCategory() {
+ GuiAction action =
+ new GuiAction(
+ "pipeline-action-table-input",
+ GuiActionType.Create,
+ "Table input",
+ "Read data from a database table",
+ "ui/images/table.svg",
+ (a, b, c) -> {});
+ action.setCategory("Input");
+
+ ContextDialog.Item item = new ContextDialog.Item(action, null);
+ AreaOwner itemArea =
+ new AreaOwner(
+ AreaOwner.AreaType.CUSTOM,
+ 10,
+ 20,
+ 80,
+ 60,
+ new DPoint(0, 0),
+ ContextDialog.OwnerType.ITEM,
+ item);
+
+ ContextDialog.CategoryAndOrder category =
+ new ContextDialog.CategoryAndOrder("Input", "001", false);
+ AreaOwner categoryArea =
+ new AreaOwner(
+ AreaOwner.AreaType.CUSTOM,
+ 10,
+ 5,
+ 200,
+ 15,
+ new DPoint(0, 0),
+ ContextDialog.OwnerType.CATEGORY,
+ category);
+
+ JsonArray array = AreaOwnerJsonSerializer.toJsonArray(List.of(itemArea,
categoryArea));
+ assertNotNull(array);
+ assertEquals(2, array.size());
+
+ JsonObject itemJson = array.get(0).asObject();
+ assertEquals(10, itemJson.get("x").asInt());
+ assertEquals(20, itemJson.get("y").asInt());
+ assertEquals(80, itemJson.get("width").asInt());
+ assertEquals(60, itemJson.get("height").asInt());
+ JsonObject itemOwner = itemJson.get("owner").asObject();
+ assertEquals("contextItem", itemOwner.get("kind").asString());
+ assertEquals("Table input", itemOwner.get("name").asString());
+ assertEquals("pipeline-action-table-input",
itemOwner.get("actionId").asString());
+ assertEquals("Read data from a database table",
itemOwner.get("tooltip").asString());
+
+ JsonObject catJson = array.get(1).asObject();
+ JsonObject catOwner = catJson.get("owner").asObject();
+ assertEquals("contextCategory", catOwner.get("kind").asString());
+ assertEquals("Input", catOwner.get("category").asString());
+ assertEquals(false, catOwner.get("collapsed").asBoolean());
+ }
+}
diff --git
a/rcp/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacadeImpl.java
b/rcp/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacadeImpl.java
new file mode 100644
index 0000000000..ce498f4b7f
--- /dev/null
+++
b/rcp/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacadeImpl.java
@@ -0,0 +1,38 @@
+/*
+ * 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.dialog;
+
+import org.eclipse.swt.widgets.Canvas;
+
+/** Desktop RCP implementation of ContextDialogSvgFacade (no-op; uses standard
SWT GC). */
+public class ContextDialogSvgFacadeImpl extends ContextDialogSvgFacade {
+
+ @Override
+ boolean isSupportedInternal() {
+ return false;
+ }
+
+ @Override
+ void registerInternal(Canvas canvas, ContextDialog dialog) {}
+
+ @Override
+ void unregisterInternal(Canvas canvas) {}
+
+ @Override
+ void renderAndPublishInternal(Canvas canvas, ContextDialog dialog) {}
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index c926282637..5a2119a19c 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -180,12 +180,12 @@ public class ContextDialog extends Dialog {
private static ContextDialog activeInstance;
- private enum OwnerType {
+ public enum OwnerType {
CATEGORY,
ITEM,
}
- private class CategoryAndOrder {
+ public static class CategoryAndOrder {
String category;
String order;
boolean collapsed;
@@ -268,7 +268,7 @@ public class ContextDialog extends Dialog {
private List<CategoryAndOrder> categories;
- private static class Item {
+ public static class Item {
private final GuiAction action;
private final Image image;
private boolean selected;
@@ -576,6 +576,10 @@ public class ContextDialog extends Dialog {
// Manually set canvas size otherwise canvas never gets drawn.
wCanvas.setSize(10, 10);
+ if (ContextDialogSvgFacade.isSupported()) {
+ ContextDialogSvgFacade.register(wCanvas, this);
+ }
+
// Show the dialog now
//
shell.open();
@@ -706,6 +710,10 @@ public class ContextDialog extends Dialog {
// Store the toolbar settings
storeDialogSettings();
+ if (ContextDialogSvgFacade.isSupported() && wCanvas != null) {
+ ContextDialogSvgFacade.unregister(wCanvas);
+ }
+
// Close the dialog window
shell.close();
@@ -753,6 +761,8 @@ public class ContextDialog extends Dialog {
toolTip = "i18n::ContextDialog.GuiAction.ShowCategories.Tooltip",
type = GuiToolbarElementType.CHECKBOX)
public void enableDisableCategories() {
+ updateToolbar();
+ previousTotalContentHeight = 0;
wCanvas.redraw();
wSearch.setFocus();
}
@@ -764,6 +774,7 @@ public class ContextDialog extends Dialog {
toolTip = "i18n::ContextDialog.GuiAction.FixedWidth.Tooltip",
type = GuiToolbarElementType.CHECKBOX)
public void enableDisableFixedWidth() {
+ previousTotalContentHeight = 0;
wCanvas.redraw();
wSearch.setFocus();
}
@@ -772,22 +783,30 @@ public class ContextDialog extends Dialog {
if (toolBarWidgets == null) {
return null;
}
+ Control control =
toolBarWidgets.findControl(TOOLBAR_ITEM_ENABLE_CATEGORIES);
+ if (control instanceof Button button) {
+ return button;
+ }
ToolItem checkboxItem =
toolBarWidgets.findToolItem(TOOLBAR_ITEM_ENABLE_CATEGORIES);
- if (checkboxItem == null) {
- return null;
+ if (checkboxItem != null && checkboxItem.getControl() instanceof Button
button) {
+ return button;
}
- return (Button) checkboxItem.getControl();
+ return null;
}
private Button getFixedWidthCheckBox() {
if (toolBarWidgets == null) {
return null;
}
+ Control control = toolBarWidgets.findControl(TOOLBAR_ITEM_FIXED_WIDTH);
+ if (control instanceof Button button) {
+ return button;
+ }
ToolItem checkboxItem =
toolBarWidgets.findToolItem(TOOLBAR_ITEM_FIXED_WIDTH);
- if (checkboxItem == null) {
- return null;
+ if (checkboxItem != null && checkboxItem.getControl() instanceof Button
button) {
+ return button;
}
- return (Button) checkboxItem.getControl();
+ return null;
}
private void onMouseMove(Event event) {
@@ -1128,29 +1147,20 @@ public class ContextDialog extends Dialog {
*/
private void onPaint(Event event) {
+ updateToolbar();
+
+ if (ContextDialogSvgFacade.isSupported()) {
+ ContextDialogSvgFacade.renderAndPublish(wCanvas, this);
+ return;
+ }
+
GC gc = event.gc;
org.eclipse.swt.graphics.Rectangle area =
wScrolledComposite.getClientArea();
org.eclipse.swt.graphics.Rectangle canvas = wCanvas.getBounds();
- boolean useCategories;
- Button categoriesCheckBox = getCategoriesCheckBox();
- if (categoriesCheckBox == null) {
- useCategories = true;
- } else {
- useCategories = categoriesCheckBox.getSelection();
- }
- useCategories &= !categories.isEmpty();
-
- boolean useFixedWidth;
- Button fixedWidthCheckBox = getFixedWidthCheckBox();
- if (fixedWidthCheckBox == null) {
- useFixedWidth = false;
- } else {
- useFixedWidth = fixedWidthCheckBox.getSelection();
- }
-
- updateToolbar();
+ boolean useCategories = isUseCategories();
+ boolean useFixedWidth = isUseFixedWidth();
// Fill everything with white...
//
@@ -1335,21 +1345,90 @@ public class ContextDialog extends Dialog {
}
}
- totalContentHeight = Math.max(area.height, y);
+ updateContentHeight(Math.max(area.height, y));
+ }
- // Content size is only known after paint. Resize the canvas and refresh
the scrollbar here.
- // updateVerticalBar() used to run only from filter()/resize *before* the
first paint, so on
- // Hop Web (RAP) the scroll range could stay stale and truncate the list
mid-way (#7868).
- int canvasWidth = wCanvas.getBounds().width;
+ void updateContentHeight(int height) {
+ totalContentHeight = height;
+ if (wScrolledComposite == null || wScrolledComposite.isDisposed()) {
+ return;
+ }
+ org.eclipse.swt.graphics.Rectangle area =
wScrolledComposite.getClientArea();
+ int canvasWidth = (wCanvas != null && !wCanvas.isDisposed()) ?
wCanvas.getBounds().width : 0;
if (previousTotalContentHeight != totalContentHeight || canvasWidth !=
area.width) {
previousTotalContentHeight = totalContentHeight;
- wCanvas.setSize(area.width, totalContentHeight);
+ if (wCanvas != null && !wCanvas.isDisposed()) {
+ wCanvas.setSize(area.width, totalContentHeight);
+ }
wScrolledComposite.setMinWidth(area.width);
wScrolledComposite.setMinHeight(totalContentHeight);
updateVerticalBar();
}
}
+ boolean isUseCategories() {
+ Button categoriesCheckBox = getCategoriesCheckBox();
+ boolean useCategories = (categoriesCheckBox == null) ||
categoriesCheckBox.getSelection();
+ return useCategories && categories != null && !categories.isEmpty();
+ }
+
+ boolean isUseFixedWidth() {
+ Button fixedWidthCheckBox = getFixedWidthCheckBox();
+ return fixedWidthCheckBox != null && fixedWidthCheckBox.getSelection();
+ }
+
+ List<CategoryAndOrder> getCategories() {
+ return categories;
+ }
+
+ List<Item> getFilteredItems() {
+ return filteredItems;
+ }
+
+ Item getSelectedItem() {
+ return selectedItem;
+ }
+
+ int getIconSize() {
+ return iconSize;
+ }
+
+ int getMargin() {
+ return margin;
+ }
+
+ int getXMargin() {
+ return xMargin;
+ }
+
+ int getYMargin() {
+ return yMargin;
+ }
+
+ ScrolledComposite getScrolledComposite() {
+ return wScrolledComposite;
+ }
+
+ Canvas getCanvas() {
+ return wCanvas;
+ }
+
+ Label getTooltipLabel() {
+ return wlTooltip;
+ }
+
+ List<AreaOwner> getAreaOwners() {
+ return areaOwners;
+ }
+
+ void setAreaOwners(List<AreaOwner> areaOwners) {
+ this.areaOwners = areaOwners;
+ }
+
+ int getTotalContentHeight() {
+ return totalContentHeight;
+ }
+
private void updateToolbar() {
if (toolBarWidgets == null) {
return;
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacade.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacade.java
new file mode 100644
index 0000000000..34d1d7caea
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgFacade.java
@@ -0,0 +1,58 @@
+/*
+ * 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.dialog;
+
+import org.apache.hop.ui.hopgui.ImplementationLoader;
+import org.eclipse.swt.widgets.Canvas;
+
+/**
+ * Facade for Hop Web server-side ContextDialog SVG rendering. Desktop (RCP)
uses a no-op
+ * implementation.
+ */
+public abstract class ContextDialogSvgFacade {
+
+ private static final ContextDialogSvgFacade IMPL;
+
+ static {
+ IMPL = (ContextDialogSvgFacade)
ImplementationLoader.newInstance(ContextDialogSvgFacade.class);
+ }
+
+ public static boolean isSupported() {
+ return IMPL.isSupportedInternal();
+ }
+
+ public static void register(Canvas canvas, ContextDialog dialog) {
+ IMPL.registerInternal(canvas, dialog);
+ }
+
+ public static void unregister(Canvas canvas) {
+ IMPL.unregisterInternal(canvas);
+ }
+
+ public static void renderAndPublish(Canvas canvas, ContextDialog dialog) {
+ IMPL.renderAndPublishInternal(canvas, dialog);
+ }
+
+ abstract boolean isSupportedInternal();
+
+ abstract void registerInternal(Canvas canvas, ContextDialog dialog);
+
+ abstract void unregisterInternal(Canvas canvas);
+
+ abstract void renderAndPublishInternal(Canvas canvas, ContextDialog dialog);
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderResult.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderResult.java
new file mode 100644
index 0000000000..9ab98597fd
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderResult.java
@@ -0,0 +1,24 @@
+/*
+ * 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.dialog;
+
+import java.util.List;
+import org.apache.hop.core.gui.AreaOwner;
+
+public record ContextDialogSvgRenderResult(
+ String svg, List<AreaOwner> areaOwners, int totalContentHeight) {}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java
new file mode 100644
index 0000000000..f924abd175
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java
@@ -0,0 +1,318 @@
+/*
+ * 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.dialog;
+
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.DPoint;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.gui.SvgGc;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.core.svg.HopSvgGraphics2D;
+import org.apache.hop.core.svg.SvgFile;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.ui.core.PropsUi;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+/** Renders ContextDialog category headers and action tiles into vector SVG
XML. */
+public final class ContextDialogSvgRenderer {
+
+ private ContextDialogSvgRenderer() {}
+
+ public static ContextDialogSvgRenderResult render(
+ ContextDialog dialog, int areaWidth, int areaHeight) throws HopException
{
+ boolean darkMode = false;
+ Map<String, String> contrastingColors = null;
+ String fontName = "Arial";
+ int fontHeight = 11;
+
+ try {
+ PropsUi props = PropsUi.getInstance();
+ darkMode = props.isDarkMode();
+ contrastingColors = darkMode ? props.getContrastingColorStrings() : null;
+ if (props.getDefaultFont() != null && props.getDefaultFont().getName()
!= null) {
+ fontName = props.getDefaultFont().getName();
+ }
+ fontHeight = (int) Math.round(props.getZoomFactor() * 11);
+ if (fontHeight <= 0) {
+ fontHeight = 11;
+ }
+ } catch (Throwable ignored) {
+ // Fallback in headless / unit-test environments without PropsUi /
Display
+ }
+ return render(dialog, areaWidth, areaHeight, darkMode, contrastingColors,
fontName, fontHeight);
+ }
+
+ public static ContextDialogSvgRenderResult render(
+ ContextDialog dialog,
+ int areaWidth,
+ int areaHeight,
+ boolean darkMode,
+ Map<String, String> contrastingColors,
+ String fontName,
+ int fontHeight)
+ throws HopException {
+ HopSvgGraphics2D graphics2D = HopSvgGraphics2D.newDocument();
+ int iconSize = dialog.getIconSize();
+ int margin = dialog.getMargin();
+ int xMargin = dialog.getXMargin();
+ int yMargin = dialog.getYMargin();
+
+ Point initialArea = new Point(Math.max(areaWidth, 100),
Math.max(areaHeight, 100));
+ SvgGc gc = new SvgGc(graphics2D, initialArea, iconSize, 0, 0, darkMode,
contrastingColors);
+
+ List<AreaOwner> areaOwners = new ArrayList<>();
+ List<ContextDialog.CategoryAndOrder> categories = dialog.getCategories();
+ List<ContextDialog.Item> filteredItems = dialog.getFilteredItems();
+ boolean useCategories = dialog.isUseCategories();
+ boolean useFixedWidth = dialog.isUseFixedWidth();
+
+ int height = 0;
+ int categoryNr = 0;
+ int x = margin;
+ int y = margin;
+
+ while ((useCategories && categories != null && categoryNr <
categories.size())
+ || (!useCategories || categories == null || categories.isEmpty()) &&
(categoryNr == 0)) {
+
+ ContextDialog.CategoryAndOrder categoryAndOrder;
+ if (!useCategories || categories == null || categories.isEmpty()) {
+ categoryAndOrder = null;
+ } else {
+ categoryAndOrder = categories.get(categoryNr);
+ }
+
+ List<ContextDialog.Item> itemsToPaint =
findItemsForCategory(filteredItems, categoryAndOrder);
+
+ if (!itemsToPaint.isEmpty()) {
+ if (categoryAndOrder != null) {
+ // Category header
+ gc.setFont(fontName, fontHeight + 1, true, true);
+ if (categoryAndOrder.isCollapsed()) {
+ if (darkMode) {
+ gc.setForeground(140, 140, 140);
+ } else {
+ gc.setForeground(128, 128, 128);
+ }
+ } else {
+ if (darkMode) {
+ gc.setForeground(240, 240, 240);
+ } else {
+ gc.setForeground(0, 0, 0);
+ }
+ }
+ Point categoryExtent = gc.textExtent(categoryAndOrder.getCategory());
+ gc.drawText(categoryAndOrder.getCategory(), x, y);
+ areaOwners.add(
+ new AreaOwner(
+ AreaOwner.AreaType.CUSTOM,
+ x,
+ y,
+ categoryExtent.x,
+ categoryExtent.y,
+ new DPoint(0, 0),
+ ContextDialog.OwnerType.CATEGORY,
+ categoryAndOrder));
+ y += categoryExtent.y + yMargin;
+ gc.setLineWidth(1);
+ if (darkMode) {
+ gc.setForeground(70, 70, 70);
+ } else {
+ gc.setForeground(215, 215, 215);
+ }
+ gc.drawLine(margin, y - yMargin, areaWidth - xMargin, y - yMargin);
+ }
+
+ if (darkMode) {
+ gc.setForeground(232, 232, 232);
+ } else {
+ gc.setForeground(0, 0, 0);
+ }
+ gc.setFont(fontName, fontHeight, false, false);
+
+ if (categoryAndOrder == null || !categoryAndOrder.isCollapsed()) {
+ Map<GuiAction, ActionDetails> detailsMap = new HashMap<>();
+
+ for (ContextDialog.Item item : itemsToPaint) {
+ ActionDetails details = new ActionDetails();
+ details.name = Const.NVL(item.getAction().getName(),
item.getAction().getId());
+ Point nameExtent = gc.textExtent(details.name);
+ details.nameExtent = new
org.eclipse.swt.graphics.Point(nameExtent.x, nameExtent.y);
+ details.width = Math.max(nameExtent.x, iconSize);
+ details.height = nameExtent.y + margin + iconSize;
+ detailsMap.put(item.getAction(), details);
+ }
+
+ if (useFixedWidth) {
+ int maxWidth = 0;
+ for (ActionDetails details : detailsMap.values()) {
+ maxWidth = Math.max(maxWidth, details.width);
+ }
+ for (ActionDetails details : detailsMap.values()) {
+ details.width = maxWidth;
+ }
+ }
+
+ for (ContextDialog.Item item : itemsToPaint) {
+ ActionDetails details = detailsMap.get(item.getAction());
+ int width = details.width;
+ height = details.height;
+
+ if (x + width + xMargin > areaWidth) {
+ x = margin;
+ y += height + yMargin;
+ }
+
+ if (item.isSelected()) {
+ if (darkMode) {
+ gc.setBackground(15, 136, 210);
+ } else {
+ gc.setBackground(201, 232, 251);
+ }
+ gc.setLineWidth(2);
+ gc.fillRoundRectangle(
+ x - xMargin / 2,
+ y - yMargin / 2,
+ width + xMargin,
+ height + yMargin,
+ margin,
+ margin);
+ }
+
+ int imageMargin = (width - iconSize) / 2;
+ if (StringUtils.isNotEmpty(item.getAction().getImage())) {
+ ClassLoader cl = item.getAction().getClassLoader();
+ if (cl == null) {
+ cl = ClassLoader.getSystemClassLoader();
+ }
+ try {
+ SvgFile svgFile = new SvgFile(item.getAction().getImage(), cl);
+ gc.drawImage(svgFile, x + imageMargin, y, iconSize, iconSize,
1.0f, 0);
+ } catch (Exception ignored) {
+ // Ignore image draw failure
+ }
+ }
+
+ gc.setFont(fontName, fontHeight, false, false);
+ if (darkMode) {
+ gc.setForeground(232, 232, 232);
+ } else {
+ gc.setForeground(0, 0, 0);
+ }
+ int textMargin = (width - details.nameExtent.x) / 2;
+ gc.drawText(details.name, x + textMargin, y + iconSize + margin);
+
+ AreaOwner areaOwner =
+ new AreaOwner(
+ AreaOwner.AreaType.CUSTOM,
+ x,
+ y,
+ width,
+ height,
+ new DPoint(0, 0),
+ ContextDialog.OwnerType.ITEM,
+ item);
+ areaOwners.add(areaOwner);
+ item.setAreaOwner(areaOwner);
+
+ x += width + xMargin;
+ if (x > areaWidth) {
+ x = margin;
+ y += height + yMargin;
+ }
+ }
+
+ x = margin;
+ y += height + yMargin;
+ } else {
+ y -= yMargin;
+ }
+ }
+
+ categoryNr++;
+ if (!itemsToPaint.isEmpty()) {
+ y += yMargin;
+ }
+ }
+
+ int totalContentHeight = Math.max(areaHeight, y);
+
+ try {
+ Element root = (Element) graphics2D.getRoot();
+ root.setAttribute("width", "100%");
+ root.setAttribute("height", String.valueOf(totalContentHeight));
+ root.setAttribute("viewBox", "0 0 " + areaWidth + " " +
totalContentHeight);
+ root.setAttribute("preserveAspectRatio", "none");
+
+ // Remove the SvgGc constructor's initial background rectangle so the
SVG remains transparent
+ // and uniformly blends with the dialog's theme background across the
entire scrollable
+ // height.
+ NodeList rects = root.getElementsByTagName("rect");
+ for (int i = 0; i < rects.getLength(); i++) {
+ Element rect = (Element) rects.item(i);
+ if ("0".equals(rect.getAttribute("x"))
+ && "0".equals(rect.getAttribute("y"))
+ && !rect.hasAttribute("rx")) {
+ rect.getParentNode().removeChild(rect);
+ break;
+ }
+ }
+
+ Transformer transformer =
XmlHandler.createSecureTransformerFactory().newTransformer();
+ transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
+ StringWriter writer = new StringWriter();
+ transformer.transform(new DOMSource(root), new StreamResult(writer));
+ return new ContextDialogSvgRenderResult(writer.toString(), areaOwners,
totalContentHeight);
+ } catch (Exception e) {
+ throw new HopException("Unable to serialize ContextDialog SVG", e);
+ }
+ }
+
+ static List<ContextDialog.Item> findItemsForCategory(
+ List<ContextDialog.Item> filteredItems, ContextDialog.CategoryAndOrder
categoryAndOrder) {
+ List<ContextDialog.Item> list = new ArrayList<>();
+ if (filteredItems == null) {
+ return list;
+ }
+ for (ContextDialog.Item filteredItem : filteredItems) {
+ if (categoryAndOrder == null
+ || categoryAndOrder
+ .getCategory()
+ .equalsIgnoreCase(filteredItem.getAction().getCategory())) {
+ list.add(filteredItem);
+ } else if
(ContextDialog.CATEGORY_OTHER.equals(categoryAndOrder.getCategory())
+ && StringUtils.isEmpty(filteredItem.getAction().getCategory())) {
+ list.add(filteredItem);
+ }
+ }
+ return list;
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
index 3b2ccb9506..5ca13f2d51 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiToolbarWidgets.java
@@ -902,6 +902,10 @@ public class GuiToolbarWidgets extends BaseGuiWidgets
implements IToolbarWidgetR
return toolItemMap.get(id);
}
+ public Control findControl(String id) {
+ return widgetsMap.get(id);
+ }
+
/**
* Return the Control to use for positioning a popup menu for the given
toolbar item. A ToolItem
* is not a Control, and getControl() is only set for SEPARATOR items (the
wrapped label/widget).
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererTest.java
b/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererTest.java
new file mode 100644
index 0000000000..8ab5b0306a
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererTest.java
@@ -0,0 +1,229 @@
+/*
+ * 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.dialog;
+
+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 static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.gui.AreaOwner;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class ContextDialogSvgRendererTest {
+
+ @Test
+ @DisplayName("findItemsForCategory matches category, case-insensitively, and
handles Other")
+ void testFindItemsForCategory() {
+ GuiAction action1 =
+ new GuiAction(
+ "id1", GuiActionType.Create, "Table input", "tooltip1",
"image1.svg", (a, b, c) -> {});
+ action1.setCategory("Transforms");
+
+ GuiAction action2 =
+ new GuiAction(
+ "id2", GuiActionType.Create, "Dummy", "tooltip2", "image2.svg",
(a, b, c) -> {});
+ action2.setCategory(null);
+
+ ContextDialog.Item item1 = new ContextDialog.Item(action1, null);
+ ContextDialog.Item item2 = new ContextDialog.Item(action2, null);
+ List<ContextDialog.Item> items = List.of(item1, item2);
+
+ ContextDialog.CategoryAndOrder catTransforms =
+ new ContextDialog.CategoryAndOrder("transforms", "1", false);
+ List<ContextDialog.Item> foundTransforms =
+ ContextDialogSvgRenderer.findItemsForCategory(items, catTransforms);
+ assertEquals(1, foundTransforms.size());
+ assertEquals(item1, foundTransforms.get(0));
+
+ ContextDialog.CategoryAndOrder catOther =
+ new ContextDialog.CategoryAndOrder(ContextDialog.CATEGORY_OTHER,
"9999", false);
+ List<ContextDialog.Item> foundOther =
+ ContextDialogSvgRenderer.findItemsForCategory(items, catOther);
+ assertEquals(1, foundOther.size());
+ assertEquals(item2, foundOther.get(0));
+
+ List<ContextDialog.Item> foundAll =
ContextDialogSvgRenderer.findItemsForCategory(items, null);
+ assertEquals(2, foundAll.size());
+ }
+
+ @Test
+ @DisplayName("render produces valid SVG with category headers and action
items")
+ void testRenderSvg() throws Exception {
+ GuiAction action1 =
+ new GuiAction(
+ "id1", GuiActionType.Create, "Table input", "Read from table",
null, (a, b, c) -> {});
+ action1.setCategory("Input");
+
+ GuiAction action2 =
+ new GuiAction("id2", GuiActionType.Create, "Dummy", "Do nothing",
null, (a, b, c) -> {});
+ action2.setCategory("Flow");
+
+ ContextDialog.Item item1 = new ContextDialog.Item(action1, null);
+ item1.setSelected(true);
+ ContextDialog.Item item2 = new ContextDialog.Item(action2, null);
+
+ List<ContextDialog.Item> items = List.of(item1, item2);
+ List<ContextDialog.CategoryAndOrder> categories = new ArrayList<>();
+ categories.add(new ContextDialog.CategoryAndOrder("Input", "1", false));
+ categories.add(new ContextDialog.CategoryAndOrder("Flow", "2", false));
+
+ ContextDialog dialog = mock(ContextDialog.class);
+ when(dialog.getIconSize()).thenReturn(32);
+ when(dialog.getMargin()).thenReturn(4);
+ when(dialog.getXMargin()).thenReturn(12);
+ when(dialog.getYMargin()).thenReturn(8);
+ when(dialog.getCategories()).thenReturn(categories);
+ when(dialog.getFilteredItems()).thenReturn(items);
+ when(dialog.isUseCategories()).thenReturn(true);
+ when(dialog.isUseFixedWidth()).thenReturn(false);
+
+ ContextDialogSvgRenderResult result =
ContextDialogSvgRenderer.render(dialog, 600, 400);
+
+ assertNotNull(result);
+ assertNotNull(result.svg());
+ assertTrue(result.svg().contains("<svg"));
+ assertTrue(result.svg().contains("preserveAspectRatio=\"none\""));
+ assertTrue(result.svg().contains("Table input"));
+ assertTrue(result.svg().contains("Dummy"));
+ assertTrue(result.totalContentHeight() > 0);
+
+ List<AreaOwner> areas = result.areaOwners();
+ assertEquals(4, areas.size()); // 2 category headers + 2 items
+ }
+
+ @Test
+ @DisplayName("collapsing a category shifts subsequent categories and items
up correctly")
+ void testRenderSvgCategoryCollapse() throws Exception {
+ GuiAction action1 =
+ new GuiAction(
+ "id1", GuiActionType.Create, "Row generator", "Generate rows",
null, (a, b, c) -> {});
+ action1.setCategory("Basic");
+
+ GuiAction action2 =
+ new GuiAction(
+ "id2",
+ GuiActionType.Create,
+ "XML input stream (StAX)",
+ "Read XML",
+ null,
+ (a, b, c) -> {});
+ action2.setCategory("Input");
+
+ ContextDialog.Item item1 = new ContextDialog.Item(action1, null);
+ ContextDialog.Item item2 = new ContextDialog.Item(action2, null);
+
+ List<ContextDialog.Item> items = List.of(item1, item2);
+ ContextDialog.CategoryAndOrder catBasic =
+ new ContextDialog.CategoryAndOrder("Basic", "1", false);
+ ContextDialog.CategoryAndOrder catInput =
+ new ContextDialog.CategoryAndOrder("Input", "2", false);
+ List<ContextDialog.CategoryAndOrder> categories = List.of(catBasic,
catInput);
+
+ ContextDialog dialog = mock(ContextDialog.class);
+ when(dialog.getIconSize()).thenReturn(32);
+ when(dialog.getMargin()).thenReturn(4);
+ when(dialog.getXMargin()).thenReturn(12);
+ when(dialog.getYMargin()).thenReturn(8);
+ when(dialog.getCategories()).thenReturn(categories);
+ when(dialog.getFilteredItems()).thenReturn(items);
+ when(dialog.isUseCategories()).thenReturn(true);
+ when(dialog.isUseFixedWidth()).thenReturn(false);
+
+ // Expanded render: both items drawn
+ ContextDialogSvgRenderResult expandedResult =
ContextDialogSvgRenderer.render(dialog, 600, 400);
+ assertEquals(4, expandedResult.areaOwners().size());
+ AreaOwner expandedItem2Area =
+ expandedResult.areaOwners().stream()
+ .filter(a -> a.getOwner() == item2)
+ .findFirst()
+ .orElseThrow();
+ assertTrue(expandedResult.svg().contains("Row generator"));
+ assertTrue(expandedResult.svg().contains("XML input stream (StAX)"));
+
+ // Collapse category "Basic"
+ catBasic.flipCollapsed();
+ assertTrue(catBasic.isCollapsed());
+
+ ContextDialogSvgRenderResult collapsedResult =
+ ContextDialogSvgRenderer.render(dialog, 600, 400);
+
+ // 3 area owners: "Basic" header, "Input" header, item2 (item1 is
collapsed)
+ assertEquals(3, collapsedResult.areaOwners().size());
+ assertFalse(collapsedResult.svg().contains("Row generator"));
+ assertTrue(collapsedResult.svg().contains("XML input stream (StAX)"));
+
+ AreaOwner collapsedItem2Area =
+ collapsedResult.areaOwners().stream()
+ .filter(a -> a.getOwner() == item2)
+ .findFirst()
+ .orElseThrow();
+
+ // Item2 must shift up when Basic is collapsed
+ assertTrue(
+ collapsedItem2Area.getArea().y < expandedItem2Area.getArea().y,
+ "Item 2 Y should shift up when previous category is collapsed");
+
+ // Total content height must decrease
+ assertTrue(collapsedResult.totalContentHeight() <=
expandedResult.totalContentHeight());
+ }
+
+ @Test
+ @DisplayName("render in dark mode produces light text and transparent
background")
+ void testRenderSvgDarkMode() throws Exception {
+ GuiAction action1 =
+ new GuiAction(
+ "id1", GuiActionType.Create, "Table input", "Read from table",
null, (a, b, c) -> {});
+ action1.setCategory("Input");
+ ContextDialog.Item item1 = new ContextDialog.Item(action1, null);
+
+ ContextDialog dialog = mock(ContextDialog.class);
+ when(dialog.getIconSize()).thenReturn(32);
+ when(dialog.getMargin()).thenReturn(4);
+ when(dialog.getXMargin()).thenReturn(12);
+ when(dialog.getYMargin()).thenReturn(8);
+ when(dialog.getCategories())
+ .thenReturn(List.of(new ContextDialog.CategoryAndOrder("Input", "1",
false)));
+ when(dialog.getFilteredItems()).thenReturn(List.of(item1));
+ when(dialog.isUseCategories()).thenReturn(true);
+ when(dialog.isUseFixedWidth()).thenReturn(false);
+
+ ContextDialogSvgRenderResult result =
+ ContextDialogSvgRenderer.render(dialog, 600, 400, true, null, "Arial",
11);
+
+ assertNotNull(result);
+ String svg = result.svg();
+ assertNotNull(svg);
+ assertTrue(svg.contains("Table input"));
+ // Verify light text color for dark mode (rgb(232, 232, 232) or #e8e8e8)
+ assertTrue(svg.contains("232, 232, 232") || svg.contains("#e8e8e8") ||
svg.contains("232"));
+ // Verify category header light text color
+ assertTrue(svg.contains("240, 240, 240") || svg.contains("#f0f0f0") ||
svg.contains("240"));
+ // Verify that the initial background rect was removed (no rect covering
(0,0) without rx)
+ assertFalse(
+ svg.contains(
+ "<rect fill=\"rgb(50, 50, 50)\" width=\"600\" height=\"400\"
x=\"0\" y=\"0\""));
+ }
+}