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\""));
+  }
+}

Reply via email to