This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 5923e312dd Issue #7114 : Add optional Spoon-style palette tree to
pipeline and workflow graphs (#8044)
5923e312dd is described below
commit 5923e312dd498c2f93d9c79fecc63ab42afa7ec3
Author: Matt Casters <[email protected]>
AuthorDate: Fri Aug 21 11:13:49 2026 +0200
Issue #7114 : Add optional Spoon-style palette tree to pipeline and
workflow graphs (#8044)
---
.../ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc | 15 +
.../hop-gui/perspective-data-orchestration.adoc | 2 +
.../org/apache/hop/ui/core/bus/HopGuiEvents.java | 6 +
.../apache/hop/ui/core/dialog/ContextDialog.java | 3 +
.../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java | 27 +-
.../ui/hopgui/context/ContextDialogPlacement.java | 32 +-
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 219 ++++++++-
.../hopgui/file/workflow/HopGuiWorkflowGraph.java | 192 +++++++-
.../apache/hop/ui/hopgui/palette/GraphPalette.java | 77 ++++
.../hop/ui/hopgui/palette/GraphPaletteModel.java | 217 +++++++++
.../hop/ui/hopgui/palette/GraphPaletteTree.java | 489 +++++++++++++++++++++
.../hop/ui/hopgui/palette/IGraphPaletteHost.java | 51 +++
.../palette/messages/messages_en_US.properties | 26 ++
.../apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java | 50 +++
.../hopgui/context/ContextDialogPlacementTest.java | 61 +++
.../ui/hopgui/palette/GraphPaletteModelTest.java | 137 ++++++
.../hop/ui/hopgui/palette/GraphPaletteTest.java | 59 +++
17 files changed, 1621 insertions(+), 42 deletions(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
index 8f6dbbdf4c..7a4fe0b706 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-gui-popup-dialog.adoc
@@ -55,6 +55,21 @@ Hover over an item to see its description and these
shortcuts in the tooltip.
TIP: Dropping a new transform or action on top of an existing hop can split
that hop (same confirmation dialog as when you move an icon onto a hop).
+[[palette-tree]]
+== Palette tree
+
+Pipeline and workflow graphs also have an optional Spoon-style palette tree on
the left of the canvas.
+
+* Use the **palette** toolbar button on the graph to show or hide the tree. It
is hidden by default; the choice is remembered across restarts.
+* Categories (with **Favorites** at the top when you have any) list every
transform or action, each with a small icon. Hover for the description.
+* Filter the tree from the text field above it. **Expand all**, **Collapse
all**, and **Clear filter** sit on the toolbar under the filter.
+* **Drag** an item onto the canvas to place it. **Shift-drag** also creates a
hop from the selected transform/action (or the last one in the graph).
+* **Double-click** (or press Enter) places the item at the last canvas click,
or at the center of the visible canvas.
+* **Shift-double-click** adds the item you clicked (not the canvas selection),
hops from the selected or last chained transform/action, places it to the
right, and selects it so the next Shift-double-click continues the chain.
+* **ALT-Click** (Option-Click on macOS) or the tree context menu adds or
removes a favorite.
+
+The canvas popup dialog described above stays available.
+
== Creating Items
When you create a new item, the dialog will show you a list of metadata items
that can be created with a single click of a button.
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
index 933e686088..78bb5417b1 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-data-orchestration.adoc
@@ -31,6 +31,8 @@ This is where xref:pipeline/pipelines.adoc[pipelines] and
xref:workflow/workflow
Pipelines and workflow files can be opened or created. At that point they
appear in new tabs in this perspective.
+An optional Spoon-style palette tree of transforms or actions can be shown on
the left of the canvas with the palette button on the graph toolbar (hidden by
default). See xref:hop-gui/hop-gui-popup-dialog.adoc#palette-tree[the palette
tree] in the popup dialog page.
+
For more information on how to get started with pipelines, see the
xref:getting-started/hop-gui-pipelines.adoc[Pipelines getting started guide].
For more information on how to get started with workflow, see the
xref:getting-started/hop-gui-workflows.adoc[Workflows getting started guide].
diff --git a/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
b/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
index be1ec30134..b5b3cbadef 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/bus/HopGuiEvents.java
@@ -46,5 +46,11 @@ public enum HopGuiEvents {
ProjectDeleted,
ProjectActivated,
ProjectDeactivated,
+
+ /** A transform or workflow-action favorite was added or removed. */
+ FavoritesChanged,
+
+ /** The Spoon-style graph palette tree was shown or hidden. */
+ PaletteTreeVisibilityChanged,
;
}
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 b908d946a1..d7a77da623 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
@@ -49,9 +49,11 @@ import org.apache.hop.ui.core.gui.HopNamespace;
import org.apache.hop.ui.core.gui.IToolbarContainer;
import org.apache.hop.ui.core.gui.WindowProperty;
import org.apache.hop.ui.core.widget.OsHelper;
+import org.apache.hop.ui.hopgui.HopGui;
import org.apache.hop.ui.hopgui.ToolbarFacade;
import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.eclipse.swt.SWT;
@@ -861,6 +863,7 @@ public class ContextDialog extends Dialog {
BaseMessages.getString(PKG,
"ContextDialog.SaveConfig.Error.Dialog.Message"),
e);
}
+ GraphPalette.fireFavoritesChanged(HopGui.getInstance());
refreshActionsFromSupplier();
return;
}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
index 7dfebf614c..f3e8bd8cae 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
@@ -425,8 +425,12 @@ public class HopGuiKeyHandler extends KeyAdapter {
/**
* Keys that text-like widgets must handle themselves:
copy/cut/paste/select-all,
- * delete/backspace, and caret / selection navigation (arrows, home/end,
page up/down) without
- * CTRL/CMD/ALT. Shift alone is allowed so Shift+Arrow selection stays in
the widget.
+ * delete/backspace, caret / selection navigation (arrows, home/end, page
up/down) without
+ * CTRL/CMD/ALT, and unmodified printable characters (including space).
+ *
+ * <p>Graph shortcuts such as Space (output fields) and {@code z} (open
referenced object) must
+ * not steal those keys from filter and search fields. App shortcuts with
CTRL/CMD/ALT (e.g.
+ * Ctrl+S) still run.
*/
private static boolean isNativeTextEditingKey(KeyEvent event) {
if ((event.stateMask & (SWT.CONTROL | SWT.COMMAND)) != 0) {
@@ -438,7 +442,24 @@ public class HopGuiKeyHandler extends KeyAdapter {
if (event.keyCode == SWT.DEL || event.character == SWT.BS) {
return true;
}
- return isCaretNavigationKey(event);
+ if (isCaretNavigationKey(event)) {
+ return true;
+ }
+ return isUnmodifiedPrintableCharacter(event);
+ }
+
+ /**
+ * Space, letters and punctuation with no CTRL/CMD/ALT. Shift may be held
for capitals. SWT
+ * reports space as {@link SWT#SPACE} and/or {@code character == ' '}.
+ */
+ private static boolean isUnmodifiedPrintableCharacter(KeyEvent event) {
+ if ((event.stateMask & (SWT.CONTROL | SWT.COMMAND | SWT.ALT)) != 0) {
+ return false;
+ }
+ if (event.keyCode == SWT.SPACE || event.character == ' ') {
+ return true;
+ }
+ return event.character >= 32 && event.character != SWT.DEL;
}
/**
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
index 3d91be0fe8..194ad9b86a 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacement.java
@@ -30,6 +30,12 @@ public final class ContextDialogPlacement {
/** TextTransfer payload prefix so drop targets ignore unrelated text. */
public static final String TRANSFER_PREFIX = "hop-context-placement:";
+ /**
+ * Same as {@link #TRANSFER_PREFIX} plus a chain marker: the drop target
hops from the selected
+ * (or last) transform/action to the newly placed one (Shift-drag from the
palette tree).
+ */
+ public static final String TRANSFER_PREFIX_CHAIN =
"hop-context-placement-chain:";
+
private ContextDialogPlacement() {
// utility
}
@@ -38,21 +44,39 @@ public final class ContextDialogPlacement {
if (action == null || StringUtils.isEmpty(action.getId())) {
return null;
}
- return TRANSFER_PREFIX + action.getId();
+ return encode(action.getId(), false);
+ }
+
+ public static String encode(String actionId, boolean chainHop) {
+ if (StringUtils.isEmpty(actionId)) {
+ return null;
+ }
+ return (chainHop ? TRANSFER_PREFIX_CHAIN : TRANSFER_PREFIX) + actionId;
}
public static boolean isPlacementPayload(Object data) {
- return data instanceof String s && s.startsWith(TRANSFER_PREFIX);
+ return data instanceof String s
+ && (s.startsWith(TRANSFER_PREFIX_CHAIN) ||
s.startsWith(TRANSFER_PREFIX));
+ }
+
+ public static boolean isChainPayload(Object data) {
+ return data instanceof String s && s.startsWith(TRANSFER_PREFIX_CHAIN);
}
/**
* @return the GuiAction id embedded in a placement payload, or null if not
a placement payload
*/
public static String decodeActionId(Object data) {
- if (!isPlacementPayload(data)) {
+ if (!(data instanceof String s)) {
return null;
}
- return ((String) data).substring(TRANSFER_PREFIX.length());
+ if (s.startsWith(TRANSFER_PREFIX_CHAIN)) {
+ return s.substring(TRANSFER_PREFIX_CHAIN.length());
+ }
+ if (s.startsWith(TRANSFER_PREFIX)) {
+ return s.substring(TRANSFER_PREFIX.length());
+ }
+ return null;
}
/**
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index eda1c64c9c..a8a00c0030 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -50,6 +50,7 @@ import org.apache.hop.core.Props;
import org.apache.hop.core.SwtUniversalImage;
import org.apache.hop.core.action.GuiContextAction;
import org.apache.hop.core.action.GuiContextActionFilter;
+import org.apache.hop.core.config.HopConfig;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.exception.HopMissingPluginsException;
import org.apache.hop.core.exception.HopPluginException;
@@ -138,6 +139,7 @@ import org.apache.hop.pipeline.transform.stream.Stream;
import org.apache.hop.pipeline.transform.stream.StreamIcon;
import org.apache.hop.ui.core.ConstUi;
import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
import org.apache.hop.ui.core.dialog.BaseDialog;
import org.apache.hop.ui.core.dialog.ContextDialog;
import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -196,11 +198,15 @@ import
org.apache.hop.ui.hopgui.file.shared.HopGuiGraphSnapshotUndo;
import org.apache.hop.ui.hopgui.file.shared.HopGuiTooltipExtension;
import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
import org.apache.hop.ui.hopgui.file.shared.PipelineRowSamplerHelper;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteTree;
+import org.apache.hop.ui.hopgui.palette.IGraphPaletteHost;
import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
+import org.apache.hop.ui.hopgui.shared.SashFormMemory;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.pipeline.dialog.PipelineDialog;
import org.apache.hop.ui.util.EnvironmentUtils;
@@ -258,7 +264,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
IHopFileTypeHandler,
IGuiRefresher,
IWebCanvasGraph,
- ISnapshotUndoSupport {
+ ISnapshotUndoSupport,
+ IGraphPaletteHost {
private static final Class<?> PKG = HopGui.class;
@@ -289,6 +296,9 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
public static final String TOOLBAR_ITEM_AUTO_LAYOUT =
"HopGuiPipelineGraph-ToolBar-10545-Auto-Layout";
+ public static final String TOOLBAR_ITEM_PALETTE_TREE =
+ "HopGuiPipelineGraph-ToolBar-10547-Palette-Tree";
+
public static final String TOOLBAR_ITEM_DESIGN_ENGINE =
"HopGuiPipelineGraph-ToolBar-10550-Design-Engine";
@@ -419,6 +429,10 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
private final List<AreaOwner> areaOwners;
+ private final SashForm paletteSash;
+
+ private GraphPaletteTree paletteTree;
+
private final SashForm sashForm;
public CTabFolder extraViewTabFolder;
@@ -580,20 +594,24 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
fdMainComposite.bottom = new FormAttachment(100, 0);
mainComposite.setLayoutData(fdMainComposite);
- // To allow for a splitter later on, we will add the splitter here...
+ // Outer sash: Spoon-style palette tree on the left, graph (+ extra view)
on the right.
//
+ paletteSash = new SashForm(mainComposite, SWT.HORIZONTAL);
+ FormData fdPaletteSash = new FormData();
+ fdPaletteSash.left = new FormAttachment(0, 0);
+ fdPaletteSash.top = new FormAttachment(0, 0);
+ fdPaletteSash.right = new FormAttachment(100, 0);
+ fdPaletteSash.bottom = new FormAttachment(100, 0);
+ paletteSash.setLayoutData(fdPaletteSash);
+
+ paletteTree = new GraphPaletteTree(paletteSash, this);
+
sashForm =
new SashForm(
- mainComposite,
+ paletteSash,
PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
? SWT.VERTICAL
: SWT.HORIZONTAL);
- FormData fdSashForm = new FormData();
- fdSashForm.left = new FormAttachment(0, 0);
- fdSashForm.top = new FormAttachment(0, 0);
- fdSashForm.right = new FormAttachment(100, 0);
- fdSashForm.bottom = new FormAttachment(100, 0);
- sashForm.setLayoutData(fdSashForm);
// Add a canvas below it, use up all space initially
//
@@ -622,6 +640,15 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
canvas.setLayoutData(fdCanvas);
sashForm.setWeights(100);
+ SashFormMemory.persist(
+ paletteSash, GraphPalette.SASH_AUDIT_KEY,
GraphPalette.DEFAULT_SASH_WEIGHTS);
+ applyPaletteVisibility();
+ hopGui
+ .getEventsHandler()
+ .addEventListener(
+ paletteListenerId(),
+ e -> applyPaletteVisibility(),
+ HopGuiEvents.PaletteTreeVisibilityChanged.name());
toolTip = new HopToolTip(getShell());
toolTip.setAutoHide(true);
@@ -667,10 +694,9 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
canvas.addMouseMoveListener(this);
canvas.addMouseTrackListener(this);
canvas.addMouseWheelListener(this::mouseScrolled);
- } else {
- // Hop Web: accept create actions dragged from the context dialog
(HTML5/SWT DnD).
- installContextDialogPlacementDropTarget();
}
+ // Palette tree (and Hop Web context dialog) place items via SWT DnD.
+ installContextDialogPlacementDropTarget();
setBackground(GuiResource.getInstance().getColorBackground());
@@ -693,6 +719,7 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
@Override
public void dispose() {
+ hopGui.getEventsHandler().removeEventListeners(paletteListenerId());
if (EnvironmentUtils.getInstance().isWeb() && canvas != null &&
!canvas.isDisposed()) {
CanvasSvgFacade.unregisterCanvas(canvas);
}
@@ -1707,7 +1734,9 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
}
// DropTargetEvent x/y are relative to the Display in SWT/RAP —
convert to canvas.
org.eclipse.swt.graphics.Point canvasPos =
canvas.toControl(event.x, event.y);
- boolean placed = placeFromContextDialogActionId(actionId,
canvasPos.x, canvasPos.y);
+ boolean chainHop =
ContextDialogPlacement.isChainPayload(event.data);
+ boolean placed =
+ placeFromContextDialogActionId(actionId, canvasPos.x,
canvasPos.y, chainHop);
if (placed) {
ContextDialogPlacement.markDropCompletedOnActiveDialog();
event.detail = DND.DROP_COPY;
@@ -1735,6 +1764,11 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
* @return true if a transform was created
*/
public boolean placeFromContextDialogActionId(String actionId, int canvasX,
int canvasY) {
+ return placeFromContextDialogActionId(actionId, canvasX, canvasY, false);
+ }
+
+ public boolean placeFromContextDialogActionId(
+ String actionId, int canvasX, int canvasY, boolean chainHop) {
GuiActionFavorites.KindAndPluginId resolved =
GuiActionFavorites.resolveFromId(actionId);
if (resolved == null || resolved.kind() !=
GuiActionFavorites.Kind.TRANSFORM) {
return false;
@@ -1742,14 +1776,99 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
if (canvas == null || canvas.isDisposed()) {
return false;
}
+ return placeTransformFromPalette(
+ resolved.pluginId(), placementLocationFromCanvas(canvasX, canvasY),
chainHop);
+ }
+
+ @Override
+ public String getPaletteHostId() {
+ return getId();
+ }
+
+ @Override
+ public GuiActionFavorites.Kind getPaletteKind() {
+ return GuiActionFavorites.Kind.TRANSFORM;
+ }
+
+ @Override
+ public boolean placePaletteAction(String actionId, Point graphLocation,
boolean chainHop) {
+ GuiActionFavorites.KindAndPluginId resolved =
GuiActionFavorites.resolveFromId(actionId);
+ if (resolved == null || resolved.kind() !=
GuiActionFavorites.Kind.TRANSFORM) {
+ return false;
+ }
+ // Keep a null location: placeTransformFromPalette then sits the item to
the right of the
+ // chain source. Filling in lastClick here is what put Shift-double-click
at (0,0).
+ return placeTransformFromPalette(resolved.pluginId(), graphLocation,
chainHop);
+ }
+
+ @Override
+ public Point getPaletteDropLocation() {
+ if (lastClick != null) {
+ return new Point(lastClick.x, lastClick.y);
+ }
+ if (canvas == null || canvas.isDisposed()) {
+ return new Point(50, 50);
+ }
+ org.eclipse.swt.graphics.Rectangle client = canvas.getClientArea();
+ Point real = screen2real(Math.max(client.width / 2, 0),
Math.max(client.height / 2, 0));
+ int half = Math.max(iconSize / 2, 1);
+ return new Point(Math.max(0, real.x - half), Math.max(0, real.y - half));
+ }
+
+ @Override
+ public void applyPaletteVisibility() {
+ if (paletteSash == null || paletteSash.isDisposed()) {
+ return;
+ }
+ boolean visible = GraphPalette.isVisible();
+ if (visible) {
+ if (paletteTree != null && !paletteTree.isDisposed()) {
+ paletteTree.ensurePopulated();
+ }
+ paletteSash.setMaximizedControl(null);
+ SashFormMemory.restore(
+ paletteSash, GraphPalette.SASH_AUDIT_KEY,
GraphPalette.DEFAULT_SASH_WEIGHTS);
+ } else {
+ paletteSash.setMaximizedControl(sashForm);
+ }
+ updatePaletteToolbarButton(visible);
+ }
+
+ @Override
+ public void persistFavoritesChange() {
+ try {
+ HopConfig.getInstance().saveToFile();
+ } catch (Exception e) {
+ log.logError("Error saving favorites", e);
+ }
+ GraphPalette.fireFavoritesChanged(hopGui);
+ }
+
+ private String paletteListenerId() {
+ return "HopGuiPipelineGraph-Palette-" + getId();
+ }
- Point location = placementLocationFromCanvas(canvasX, canvasY);
+ private void updatePaletteToolbarButton(boolean visible) {
+ if (toolBarWidgets == null) {
+ return;
+ }
+ toolBarWidgets.setToolbarItemToolTip(
+ TOOLBAR_ITEM_PALETTE_TREE,
+ BaseMessages.getString(
+ org.apache.hop.ui.hopgui.palette.GraphPaletteTree.class,
+ visible ? "GraphPalette.Toolbar.Hide.Tooltip" :
"GraphPalette.Toolbar.Show.Tooltip"));
+ }
+
+ private boolean placeTransformFromPalette(String pluginId, Point location,
boolean chainHop) {
+ TransformMeta chainSource = chainHop ? resolveChainSource() : null;
+ if (location == null) {
+ location = chainHop ? locationAfter(chainSource) :
getPaletteDropLocation();
+ }
int half = Math.max(iconSize / 2, 1);
- String pluginName = resolved.pluginId();
+ String pluginName = pluginId;
try {
IPlugin plugin =
- PluginRegistry.getInstance()
- .findPluginWithId(TransformPluginType.class,
resolved.pluginId());
+
PluginRegistry.getInstance().findPluginWithId(TransformPluginType.class,
pluginId);
if (plugin != null && plugin.getName() != null) {
pluginName = plugin.getName();
}
@@ -1759,21 +1878,30 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
TransformMeta transformMeta =
pipelineTransformDelegate.newTransform(
- pipelineMeta, resolved.pluginId(), pluginName, pluginName, false,
true, location);
+ pipelineMeta, pluginId, pluginName, pluginName, false, true,
location);
if (transformMeta == null) {
return false;
}
PipelineHopMeta hop = findPipelineHop(location.x + half, location.y +
half, transformMeta);
- if (hop != null
- && pipelineMeta.findPipelineHop(transformMeta, hop.getFromTransform())
== null
- && pipelineMeta.findPipelineHop(transformMeta, hop.getToTransform())
== null
- && pipelineMeta.findPipelineHop(hop.getToTransform(), transformMeta)
== null
- && pipelineMeta.findPipelineHop(hop.getFromTransform(), transformMeta)
== null) {
+ boolean split =
+ hop != null
+ && pipelineMeta.findPipelineHop(transformMeta,
hop.getFromTransform()) == null
+ && pipelineMeta.findPipelineHop(transformMeta,
hop.getToTransform()) == null
+ && pipelineMeta.findPipelineHop(hop.getToTransform(),
transformMeta) == null
+ && pipelineMeta.findPipelineHop(hop.getFromTransform(),
transformMeta) == null;
+ if (split) {
currentTransform = transformMeta;
splitHop(hop);
+ } else if (chainHop
+ && chainSource != null
+ && chainSource != transformMeta
+ && pipelineMeta.findPipelineHop(chainSource, transformMeta) == null) {
+ pipelineHopDelegate.newHop(pipelineMeta, new
PipelineHopMeta(chainSource, transformMeta));
}
+ lastChained = transformMeta;
+ lastClick = new Point(location.x, location.y);
pipelineMeta.unselectAll();
transformMeta.setSelected(true);
avoidContextDialog = true;
@@ -1782,6 +1910,36 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
return true;
}
+ private TransformMeta resolveChainSource() {
+ if (lastChained != null &&
pipelineMeta.findTransform(lastChained.getName()) == null) {
+ lastChained = null;
+ }
+ List<TransformMeta> selected = pipelineMeta.getSelectedTransforms();
+ if (selected != null && selected.size() == 1) {
+ return selected.get(0);
+ }
+ if (lastChained != null) {
+ return lastChained;
+ }
+ int n = pipelineMeta.nrTransforms();
+ return n > 0 ? pipelineMeta.getTransform(n - 1) : null;
+ }
+
+ /**
+ * Place the next chained transform to the right of {@code source}. When
hopping from an existing
+ * transform the new icon sits {@link GraphPalette#CHAIN_OFFSET_X} further
right.
+ */
+ private Point locationAfter(TransformMeta source) {
+ if (source == null) {
+ Point p = pipelineMeta.getMaximum();
+ p.x -= 100;
+ p.x += 200;
+ return p;
+ }
+ Point loc = source.getLocation();
+ return new Point(loc.x + GraphPalette.CHAIN_OFFSET_X, loc.y);
+ }
+
/**
* Start a placement drag from the context dialog (issue #3111). The dialog
has already closed. A
* ghost transform is created when the pointer first moves over the canvas
so the icon is visible
@@ -2861,6 +3019,20 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
String selected = combo.getText();
String engineId =
PaletteEngineFilter.getPipelineEngineIdForLabel(selected);
PaletteEngineFilter.setPipelineDesignEngineId(engineId);
+ if (paletteTree != null && !paletteTree.isDisposed()) {
+ paletteTree.refresh();
+ }
+ }
+
+ @GuiToolbarElement(
+ root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = TOOLBAR_ITEM_PALETTE_TREE,
+ toolTip =
"i18n:org.apache.hop.ui.hopgui.palette:GraphPalette.Toolbar.Show.Tooltip",
+ image = "ui/images/palette.svg",
+ separator = true)
+ public void togglePaletteTree() {
+ GraphPalette.setVisible(!GraphPalette.isVisible());
+ GraphPalette.fireVisibilityChanged(hopGui);
}
/** Combo values for {@link #TOOLBAR_ITEM_DESIGN_ENGINE} — referenced by
reflection. */
@@ -6378,6 +6550,7 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
updateGui();
}
+ @Override
public HopGui getHopGui() {
return hopGui;
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 8b26c9e414..d276a92fb9 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -45,6 +45,7 @@ import org.apache.hop.core.ResultFile;
import org.apache.hop.core.RowMetaAndData;
import org.apache.hop.core.action.GuiContextAction;
import org.apache.hop.core.action.GuiContextActionFilter;
+import org.apache.hop.core.config.HopConfig;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.exception.HopPluginException;
import org.apache.hop.core.exception.HopXmlException;
@@ -102,6 +103,7 @@ import
org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
import org.apache.hop.pipeline.PipelinePainter;
import org.apache.hop.ui.core.ConstUi;
import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
import org.apache.hop.ui.core.dialog.BaseDialog;
import org.apache.hop.ui.core.dialog.ContextDialog;
import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -150,11 +152,15 @@ import
org.apache.hop.ui.hopgui.file.workflow.delegates.HopGuiWorkflowLogDelegat
import
org.apache.hop.ui.hopgui.file.workflow.delegates.HopGuiWorkflowRunDelegate;
import
org.apache.hop.ui.hopgui.file.workflow.delegates.HopGuiWorkflowUndoDelegate;
import
org.apache.hop.ui.hopgui.file.workflow.extension.HopGuiWorkflowGraphExtension;
+import org.apache.hop.ui.hopgui.palette.GraphPalette;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteTree;
+import org.apache.hop.ui.hopgui.palette.IGraphPaletteHost;
import org.apache.hop.ui.hopgui.perspective.execution.ExecutionPerspective;
import org.apache.hop.ui.hopgui.perspective.execution.IExecutionViewer;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import org.apache.hop.ui.hopgui.shared.CanvasZoomHelper;
import org.apache.hop.ui.hopgui.shared.IWebCanvasGraph;
+import org.apache.hop.ui.hopgui.shared.SashFormMemory;
import org.apache.hop.ui.hopgui.shared.SwtGc;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.apache.hop.ui.util.HelpUtils;
@@ -220,6 +226,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
IHopFileTypeHandler,
IGuiRefresher,
IWebCanvasGraph,
+ IGraphPaletteHost,
ISnapshotUndoSupport {
private static final Class<?> PKG = HopGuiWorkflowGraph.class;
@@ -252,6 +259,9 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
public static final String TOOLBAR_ITEM_DESIGN_ENGINE =
"HopGuiWorkflowGraph-ToolBar-10550-Design-Engine";
+ public static final String TOOLBAR_ITEM_PALETTE_TREE =
+ "HopGuiWorkflowGraph-ToolBar-10537-Palette-Tree";
+
public static final String TOOLBAR_ITEM_EDIT_WORKFLOW =
"HopGuiWorkflowGraph-ToolBar-10450-EditWorkflow";
@@ -368,6 +378,10 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
@Getter private Object canvasZoomHandler; // For web/RAP zoom handling
+ private SashForm paletteSash;
+
+ private GraphPaletteTree paletteTree;
+
private SashForm sashForm;
public CTabFolder extraViewTabFolder;
@@ -490,11 +504,14 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
fdMainComposite.bottom = new FormAttachment(100, 0);
mainComposite.setLayoutData(fdMainComposite);
- // To allow for a splitter later on, we will add the splitter here...
+ // Outer sash: Spoon-style palette tree on the left, graph (+ extra view)
on the right.
//
+ paletteSash = new SashForm(mainComposite, SWT.HORIZONTAL);
+ paletteTree = new GraphPaletteTree(paletteSash, this);
+
sashForm =
new SashForm(
- mainComposite,
+ paletteSash,
PropsUi.getInstance().isGraphExtraViewVerticalOrientation()
? SWT.VERTICAL
: SWT.HORIZONTAL);
@@ -526,6 +543,15 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
canvas.setLayoutData(fdCanvas);
sashForm.setWeights(100);
+ SashFormMemory.persist(
+ paletteSash, GraphPalette.SASH_AUDIT_KEY,
GraphPalette.DEFAULT_SASH_WEIGHTS);
+ applyPaletteVisibility();
+ hopGui
+ .getEventsHandler()
+ .addEventListener(
+ paletteListenerId(),
+ e -> applyPaletteVisibility(),
+ HopGuiEvents.PaletteTreeVisibilityChanged.name());
toolTip = new HopToolTip(getShell());
toolTip.setAutoHide(true);
@@ -572,10 +598,9 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
canvas.addMouseMoveListener(this);
canvas.addMouseTrackListener(this);
canvas.addMouseWheelListener(this::mouseScrolled);
- } else {
- // Hop Web: accept create actions dragged from the context dialog
(HTML5/SWT DnD).
- installContextDialogPlacementDropTarget();
}
+ // Palette tree (and Hop Web context dialog) place items via SWT DnD.
+ installContextDialogPlacementDropTarget();
hopGui.replaceKeyboardShortcutListeners(this);
setBackground(GuiResource.getInstance().getColorBackground());
@@ -593,6 +618,7 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
@Override
public void dispose() {
+ hopGui.getEventsHandler().removeEventListeners(paletteListenerId());
if (EnvironmentUtils.getInstance().isWeb() && canvas != null &&
!canvas.isDisposed()) {
CanvasSvgFacade.unregisterCanvas(canvas);
}
@@ -1452,7 +1478,9 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
// DropTargetEvent x/y are relative to the Display in SWT/RAP —
convert to canvas.
org.eclipse.swt.graphics.Point canvasPos =
canvas.toControl(event.x, event.y);
- boolean placed = placeFromContextDialogActionId(actionId,
canvasPos.x, canvasPos.y);
+ boolean chainHop =
ContextDialogPlacement.isChainPayload(event.data);
+ boolean placed =
+ placeFromContextDialogActionId(actionId, canvasPos.x,
canvasPos.y, chainHop);
if (placed) {
ContextDialogPlacement.markDropCompletedOnActiveDialog();
event.detail = DND.DROP_COPY;
@@ -1480,6 +1508,11 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
* @return true if an action was created
*/
public boolean placeFromContextDialogActionId(String actionId, int canvasX,
int canvasY) {
+ return placeFromContextDialogActionId(actionId, canvasX, canvasY, false);
+ }
+
+ public boolean placeFromContextDialogActionId(
+ String actionId, int canvasX, int canvasY, boolean chainHop) {
GuiActionFavorites.KindAndPluginId resolved =
GuiActionFavorites.resolveFromId(actionId);
if (resolved == null || resolved.kind() !=
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
return false;
@@ -1487,14 +1520,99 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
if (canvas == null || canvas.isDisposed()) {
return false;
}
+ return placeActionFromPalette(
+ resolved.pluginId(), placementLocationFromCanvas(canvasX, canvasY),
chainHop);
+ }
- Point location = placementLocationFromCanvas(canvasX, canvasY);
+ @Override
+ public String getPaletteHostId() {
+ return getId();
+ }
+
+ @Override
+ public GuiActionFavorites.Kind getPaletteKind() {
+ return GuiActionFavorites.Kind.WORKFLOW_ACTION;
+ }
+
+ @Override
+ public boolean placePaletteAction(String actionId, Point graphLocation,
boolean chainHop) {
+ GuiActionFavorites.KindAndPluginId resolved =
GuiActionFavorites.resolveFromId(actionId);
+ if (resolved == null || resolved.kind() !=
GuiActionFavorites.Kind.WORKFLOW_ACTION) {
+ return false;
+ }
+ // Keep a null location: placeActionFromPalette then sits the item to the
right of the
+ // chain source. Filling in lastClick here is what put Shift-double-click
at (0,0).
+ return placeActionFromPalette(resolved.pluginId(), graphLocation,
chainHop);
+ }
+
+ @Override
+ public Point getPaletteDropLocation() {
+ if (lastClick != null) {
+ return new Point(lastClick.x, lastClick.y);
+ }
+ if (canvas == null || canvas.isDisposed()) {
+ return new Point(50, 50);
+ }
+ org.eclipse.swt.graphics.Rectangle client = canvas.getClientArea();
+ Point real = screen2real(Math.max(client.width / 2, 0),
Math.max(client.height / 2, 0));
int half = Math.max(iconSize / 2, 1);
- String pluginName = resolved.pluginId();
+ return new Point(Math.max(0, real.x - half), Math.max(0, real.y - half));
+ }
+
+ @Override
+ public void applyPaletteVisibility() {
+ if (paletteSash == null || paletteSash.isDisposed()) {
+ return;
+ }
+ boolean visible = GraphPalette.isVisible();
+ if (visible) {
+ if (paletteTree != null && !paletteTree.isDisposed()) {
+ paletteTree.ensurePopulated();
+ }
+ paletteSash.setMaximizedControl(null);
+ SashFormMemory.restore(
+ paletteSash, GraphPalette.SASH_AUDIT_KEY,
GraphPalette.DEFAULT_SASH_WEIGHTS);
+ } else {
+ paletteSash.setMaximizedControl(sashForm);
+ }
+ updatePaletteToolbarButton(visible);
+ }
+
+ @Override
+ public void persistFavoritesChange() {
+ try {
+ HopConfig.getInstance().saveToFile();
+ } catch (Exception e) {
+ log.logError("Error saving favorites", e);
+ }
+ GraphPalette.fireFavoritesChanged(hopGui);
+ }
+
+ private String paletteListenerId() {
+ return "HopGuiWorkflowGraph-Palette-" + getId();
+ }
+
+ private void updatePaletteToolbarButton(boolean visible) {
+ if (toolBarWidgets == null) {
+ return;
+ }
+ toolBarWidgets.setToolbarItemToolTip(
+ TOOLBAR_ITEM_PALETTE_TREE,
+ BaseMessages.getString(
+ org.apache.hop.ui.hopgui.palette.GraphPaletteTree.class,
+ visible ? "GraphPalette.Toolbar.Hide.Tooltip" :
"GraphPalette.Toolbar.Show.Tooltip"));
+ }
+
+ private boolean placeActionFromPalette(String pluginId, Point location,
boolean chainHop) {
+ ActionMeta chainSource = chainHop ? resolveChainSource() : null;
+ if (location == null) {
+ location = chainHop ? locationAfter(chainSource) :
getPaletteDropLocation();
+ }
+ int half = Math.max(iconSize / 2, 1);
+ String pluginName = pluginId;
try {
IPlugin plugin =
- PluginRegistry.getInstance()
- .findPluginWithId(ActionPluginType.class, resolved.pluginId());
+
PluginRegistry.getInstance().findPluginWithId(ActionPluginType.class, pluginId);
if (plugin != null && plugin.getName() != null) {
pluginName = plugin.getName();
}
@@ -1503,8 +1621,7 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
ActionMeta actionMeta =
- workflowActionDelegate.newAction(
- workflowMeta, resolved.pluginId(), pluginName, false, location);
+ workflowActionDelegate.newAction(workflowMeta, pluginId, pluginName,
false, location);
if (actionMeta == null) {
return false;
}
@@ -1534,8 +1651,15 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
if ((id & 0xFF) == 0) {
workflowActionDelegate.insertAction(workflowMeta, hop, actionMeta);
}
+ } else if (chainHop
+ && chainSource != null
+ && chainSource != actionMeta
+ && workflowMeta.findWorkflowHop(chainSource, actionMeta) == null) {
+ workflowHopDelegate.newHop(workflowMeta, chainSource, actionMeta);
}
+ lastChained = actionMeta;
+ lastClick = new Point(location.x, location.y);
workflowMeta.unselectAll();
actionMeta.setSelected(true);
avoidContextDialog = true;
@@ -1543,6 +1667,36 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
return true;
}
+ private ActionMeta resolveChainSource() {
+ if (lastChained != null && workflowMeta.findAction(lastChained.getName())
== null) {
+ lastChained = null;
+ }
+ List<ActionMeta> selected = workflowMeta.getSelectedActions();
+ if (selected != null && selected.size() == 1) {
+ return selected.get(0);
+ }
+ if (lastChained != null) {
+ return lastChained;
+ }
+ int n = workflowMeta.nrActions();
+ return n > 0 ? workflowMeta.getAction(n - 1) : null;
+ }
+
+ /**
+ * Place the next chained action to the right of {@code source}. When
hopping from an existing
+ * action the new icon sits {@link GraphPalette#CHAIN_OFFSET_X} further
right.
+ */
+ private Point locationAfter(ActionMeta source) {
+ if (source == null) {
+ Point p = workflowMeta.getMaximum();
+ p.x -= 100;
+ p.x += 200;
+ return p;
+ }
+ Point loc = source.getLocation();
+ return new Point(loc.x + GraphPalette.CHAIN_OFFSET_X, loc.y);
+ }
+
/**
* Start a placement drag from the context dialog (issue #3111). The dialog
has already closed. A
* ghost action is created when the pointer first moves over the canvas so
the icon is visible
@@ -2338,6 +2492,20 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
String selected = combo.getText();
String engineId =
PaletteEngineFilter.getWorkflowEngineIdForLabel(selected);
PaletteEngineFilter.setWorkflowDesignEngineId(engineId);
+ if (paletteTree != null && !paletteTree.isDisposed()) {
+ paletteTree.refresh();
+ }
+ }
+
+ @GuiToolbarElement(
+ root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = TOOLBAR_ITEM_PALETTE_TREE,
+ toolTip =
"i18n:org.apache.hop.ui.hopgui.palette:GraphPalette.Toolbar.Show.Tooltip",
+ image = "ui/images/palette.svg",
+ separator = true)
+ public void togglePaletteTree() {
+ GraphPalette.setVisible(!GraphPalette.isVisible());
+ GraphPalette.fireVisibilityChanged(hopGui);
}
/** Combo values for {@link #TOOLBAR_ITEM_DESIGN_ENGINE} — referenced by
reflection. */
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPalette.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPalette.java
new file mode 100644
index 0000000000..e23c707ffd
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPalette.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.palette;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
+import org.apache.hop.ui.hopgui.HopGui;
+
+/**
+ * Visibility and sash layout for the Spoon-style transform/action palette
tree (issue #7114).
+ * Hidden by default; the choice is stored in hop-config.json so it applies to
new pipeline and
+ * workflow tabs.
+ */
+public final class GraphPalette {
+
+ public static final String CONFIG_KEY = "ShowGraphPaletteTree";
+ public static final String SASH_AUDIT_KEY = "graph-palette-tree-width";
+ public static final int[] DEFAULT_SASH_WEIGHTS = {20, 80};
+
+ /** Horizontal offset when Shift-double-click hops from an existing
transform or action. */
+ public static final int CHAIN_OFFSET_X = 150;
+
+ private GraphPalette() {
+ // utility
+ }
+
+ /** False when the property is missing or not {@code Y} — the palette stays
hidden by default. */
+ public static boolean isVisible() {
+ return
"Y".equalsIgnoreCase(Const.NVL(HopConfig.getGuiProperty(CONFIG_KEY), "N"));
+ }
+
+ public static void setVisible(boolean visible) {
+ HopConfig.setGuiProperty(CONFIG_KEY, visible ? "Y" : "N");
+ try {
+ HopConfig.getInstance().saveToFile();
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error saving palette tree visibility", e);
+ }
+ }
+
+ public static void fireFavoritesChanged(HopGui hopGui) {
+ fireEvent(hopGui, HopGuiEvents.FavoritesChanged);
+ }
+
+ public static void fireVisibilityChanged(HopGui hopGui) {
+ fireEvent(hopGui, HopGuiEvents.PaletteTreeVisibilityChanged);
+ }
+
+ private static void fireEvent(HopGui hopGui, HopGuiEvents event) {
+ if (hopGui == null) {
+ return;
+ }
+ try {
+ hopGui.getEventsHandler().fire(null, true, event.name());
+ } catch (HopException e) {
+ LogChannel.UI.logError("Error firing " + event.name(), e);
+ }
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModel.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModel.java
new file mode 100644
index 0000000000..6a065fcf5c
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModel.java
@@ -0,0 +1,217 @@
+/*
+ * 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.palette;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.plugins.ActionPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.ui.core.dialog.ContextDialog;
+import org.apache.hop.ui.hopgui.PaletteEngineFilter;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites.Kind;
+
+/**
+ * Categorized list of pipeline transforms or workflow actions for the
Spoon-style palette tree.
+ * Favorites (when any exist) are always the first category.
+ */
+@Getter
+public final class GraphPaletteModel {
+
+ public record Item(
+ String pluginId,
+ String actionId,
+ String name,
+ String category,
+ String description,
+ String imageFile,
+ List<String> keywords) {
+
+ public Item {
+ keywords = keywords == null ? List.of() : List.copyOf(keywords);
+ name = Const.NVL(name, pluginId);
+ category = Const.NVL(category, ContextDialog.CATEGORY_OTHER);
+ description = Const.NVL(description, "");
+ imageFile = Const.NVL(imageFile, "");
+ }
+
+ /**
+ * Same field weighting as {@link
org.apache.hop.core.gui.plugin.action.GuiAction#matchScore}.
+ */
+ public double matchScore(SearchMatcher matcher) {
+ if (matcher == null) {
+ return 1.0;
+ }
+ double best = matcher.score(name);
+ best = Math.max(best, 0.9 * matcher.score(description));
+ if (keywords != null) {
+ for (String keyword : keywords) {
+ best = Math.max(best, 0.8 * matcher.score(keyword));
+ }
+ }
+ return Math.max(best, 0.7 * matcher.score(category));
+ }
+ }
+
+ public record Category(String name, List<Item> items) {
+ public Category {
+ items = items == null ? List.of() : List.copyOf(items);
+ name = Const.NVL(name, ContextDialog.CATEGORY_OTHER);
+ }
+ }
+
+ private final List<Category> categories;
+
+ public GraphPaletteModel(List<Category> categories) {
+ this.categories = categories == null ? List.of() : List.copyOf(categories);
+ }
+
+ public static GraphPaletteModel fromPlugins(Kind kind) {
+ PaletteEngineFilter filter =
+ kind == Kind.TRANSFORM
+ ? PaletteEngineFilter.forPipelineDesign()
+ : PaletteEngineFilter.forWorkflowDesign();
+ PluginRegistry registry = PluginRegistry.getInstance();
+ List<IPlugin> plugins =
+ kind == Kind.TRANSFORM
+ ? registry.getPlugins(TransformPluginType.class)
+ : registry.getPlugins(ActionPluginType.class);
+ List<Item> items = new ArrayList<>();
+ for (IPlugin plugin : plugins) {
+ if (!filter.isPluginAllowed(plugin)
+ || plugin.getIds() == null
+ || plugin.getIds().length == 0) {
+ continue;
+ }
+ String pluginId = plugin.getIds()[0];
+ List<String> keywords = new ArrayList<>();
+ if (plugin.getKeywords() != null) {
+ keywords.addAll(Arrays.asList(plugin.getKeywords()));
+ }
+ if (plugin.getEnglishKeywords() != null) {
+ keywords.addAll(Arrays.asList(plugin.getEnglishKeywords()));
+ }
+ if (StringUtils.isNotEmpty(plugin.getCategory())) {
+ keywords.add(plugin.getCategory());
+ }
+ items.add(
+ new Item(
+ pluginId,
+ GuiActionFavorites.createId(kind, pluginId),
+ plugin.getName(),
+ plugin.getCategory(),
+ plugin.getDescription(),
+ plugin.getImageFile(),
+ keywords));
+ }
+ return fromItems(items, GuiActionFavorites.getFavoriteIds(kind),
favoritesCategoryName());
+ }
+
+ public static String favoritesCategoryName() {
+ return GuiActionFavorites.getFavoritesCategoryName();
+ }
+
+ /**
+ * Group {@code items} into categories. Favorites (ids that still exist in
{@code items}) come
+ * first, in the stored favorite order. Remaining categories are
alphabetical; items within a
+ * category are alphabetical by name.
+ */
+ public static GraphPaletteModel fromItems(
+ List<Item> items, List<String> favoriteIds, String
favoritesCategoryName) {
+ Map<String, Item> byPluginId = new LinkedHashMap<>();
+ Map<String, List<Item>> byCategory = new LinkedHashMap<>();
+ if (items != null) {
+ for (Item item : items) {
+ if (item == null || StringUtils.isEmpty(item.pluginId())) {
+ continue;
+ }
+ byPluginId.put(item.pluginId(), item);
+ byCategory.computeIfAbsent(item.category(), k -> new
ArrayList<>()).add(item);
+ }
+ }
+
+ List<Category> result = new ArrayList<>();
+ if (favoriteIds != null && !favoriteIds.isEmpty()) {
+ List<Item> favorites = new ArrayList<>();
+ for (String favoriteId : favoriteIds) {
+ Item item = byPluginId.get(favoriteId);
+ if (item != null) {
+ favorites.add(item);
+ }
+ }
+ if (!favorites.isEmpty()) {
+ result.add(new Category(Const.NVL(favoritesCategoryName, "Favorites"),
favorites));
+ }
+ }
+
+ List<String> categoryNames = new ArrayList<>(byCategory.keySet());
+ categoryNames.sort(String.CASE_INSENSITIVE_ORDER);
+ for (String categoryName : categoryNames) {
+ List<Item> categoryItems = new ArrayList<>(byCategory.get(categoryName));
+ categoryItems.sort(Comparator.comparing(Item::name,
String.CASE_INSENSITIVE_ORDER));
+ result.add(new Category(categoryName, categoryItems));
+ }
+ return new GraphPaletteModel(result);
+ }
+
+ /**
+ * Filter categories/items with the same matcher as the context dialog.
Empty categories are
+ * omitted. When {@code text} is blank the full model is returned.
+ */
+ public List<Category> filter(String text) {
+ if (StringUtils.isEmpty(text)) {
+ return categories;
+ }
+ SearchMatcher matcher = new SearchMatcher(text, false, false, true);
+ List<Category> result = new ArrayList<>();
+ for (Category category : categories) {
+ List<Item> matched = new ArrayList<>();
+ Map<Item, Double> scores = new LinkedHashMap<>();
+ for (Item item : category.items()) {
+ double score = item.matchScore(matcher);
+ if (score > 0.0) {
+ scores.put(item, score);
+ matched.add(item);
+ }
+ }
+ if (matched.isEmpty()) {
+ continue;
+ }
+ matched.sort(
+ (a, b) -> {
+ int byScore = Double.compare(scores.get(b), scores.get(a));
+ if (byScore != 0) {
+ return byScore;
+ }
+ return String.CASE_INSENSITIVE_ORDER.compare(a.name(), b.name());
+ });
+ result.add(new Category(category.name(), matched));
+ }
+ return result;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
new file mode 100644
index 0000000000..2510e98746
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTree.java
@@ -0,0 +1,489 @@
+/*
+ * 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.palette;
+
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.TreeMemory;
+import org.apache.hop.ui.hopgui.context.ContextDialogPlacement;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites.Kind;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Category;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Item;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.dnd.DND;
+import org.eclipse.swt.dnd.DragSource;
+import org.eclipse.swt.dnd.DragSourceAdapter;
+import org.eclipse.swt.dnd.DragSourceEvent;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
+import org.eclipse.swt.graphics.GC;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Listener;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swt.widgets.ToolItem;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
+
+/** Spoon-style categorized tree of transforms or workflow actions (issue
#7114). */
+public class GraphPaletteTree extends Composite {
+
+ private static final Class<?> PKG = GraphPaletteTree.class;
+
+ private static final String TREE_MEMORY_PIPELINE = "PipelinePaletteTree";
+ private static final String TREE_MEMORY_WORKFLOW = "WorkflowPaletteTree";
+
+ private final IGraphPaletteHost host;
+ private final String treeMemoryName;
+ private final String eventGuiId;
+
+ private Text filterText;
+ private Tree tree;
+ private GraphPaletteModel model;
+ private boolean populated;
+ private boolean dirty = true;
+ private boolean shiftHeld;
+ private boolean altHeld;
+ private Listener shiftKeyFilter;
+ private Image dragImage;
+
+ public GraphPaletteTree(Composite parent, IGraphPaletteHost host) {
+ super(parent, SWT.NONE);
+ this.host = host;
+ this.treeMemoryName =
+ host.getPaletteKind() == Kind.TRANSFORM ? TREE_MEMORY_PIPELINE :
TREE_MEMORY_WORKFLOW;
+ this.eventGuiId = "GraphPaletteTree-" + host.getPaletteHostId();
+
+ FormLayout layout = new FormLayout();
+ layout.marginWidth = 0;
+ layout.marginHeight = 0;
+ setLayout(layout);
+ PropsUi.setLook(this);
+
+ createFilter();
+ ToolBar toolBar = createToolbar();
+ createTree(toolBar);
+ createContextMenu();
+ createDragSource();
+
+ host.getHopGui()
+ .getEventsHandler()
+ .addEventListener(eventGuiId, e -> asyncRefresh(),
HopGuiEvents.FavoritesChanged.name());
+
+ addDisposeListener(e ->
host.getHopGui().getEventsHandler().removeEventListeners(eventGuiId));
+ }
+
+ private void createFilter() {
+ filterText = new Text(this, SWT.SEARCH | SWT.ICON_SEARCH |
SWT.ICON_CANCEL);
+ PropsUi.setLook(filterText);
+ filterText.setMessage(
+ BaseMessages.getString(
+ PKG,
+ host.getPaletteKind() == Kind.TRANSFORM
+ ? "GraphPalette.Filter.Transforms.Placeholder"
+ : "GraphPalette.Filter.Actions.Placeholder"));
+ FormData fd = new FormData();
+ fd.left = new FormAttachment(0, 0);
+ fd.top = new FormAttachment(0, 0);
+ fd.right = new FormAttachment(100, 0);
+ filterText.setLayoutData(fd);
+ filterText.addListener(SWT.Modify, e -> rebuildTree());
+ filterText.addListener(
+ SWT.DefaultSelection,
+ e -> {
+ if (e.detail == SWT.ICON_CANCEL) {
+ clearFilter();
+ }
+ });
+ }
+
+ private ToolBar createToolbar() {
+ ToolBar toolBar = new ToolBar(this, SWT.FLAT | SWT.HORIZONTAL | SWT.WRAP);
+ PropsUi.setLook(toolBar, org.apache.hop.core.Props.WIDGET_STYLE_TOOLBAR);
+ FormData fd = new FormData();
+ fd.left = new FormAttachment(0, 0);
+ fd.top = new FormAttachment(filterText, PropsUi.getMargin());
+ fd.right = new FormAttachment(100, 0);
+ toolBar.setLayoutData(fd);
+
+ GuiResource images = GuiResource.getInstance();
+ ToolItem expand = new ToolItem(toolBar, SWT.PUSH);
+ expand.setImage(images.getImage("ui/images/expand-all.svg"));
+ expand.setToolTipText(BaseMessages.getString(PKG,
"GraphPalette.Toolbar.ExpandAll.Tooltip"));
+ expand.addListener(SWT.Selection, e -> expandAll(true));
+
+ ToolItem collapse = new ToolItem(toolBar, SWT.PUSH);
+ collapse.setImage(images.getImage("ui/images/collapse-all.svg"));
+ collapse.setToolTipText(
+ BaseMessages.getString(PKG,
"GraphPalette.Toolbar.CollapseAll.Tooltip"));
+ collapse.addListener(SWT.Selection, e -> expandAll(false));
+
+ ToolItem clear = new ToolItem(toolBar, SWT.PUSH);
+ clear.setImage(images.getImage("ui/images/clear.svg"));
+ clear.setToolTipText(BaseMessages.getString(PKG,
"GraphPalette.Toolbar.ClearFilter.Tooltip"));
+ clear.addListener(SWT.Selection, e -> clearFilter());
+
+ toolBar.pack();
+ return toolBar;
+ }
+
+ private void createTree(ToolBar toolBar) {
+ Composite border = new Composite(this, SWT.BORDER);
+ border.setLayout(new FormLayout());
+ FormData borderFd = new FormData();
+ borderFd.left = new FormAttachment(0, 0);
+ borderFd.top = new FormAttachment(toolBar, PropsUi.getMargin());
+ borderFd.right = new FormAttachment(100, 0);
+ borderFd.bottom = new FormAttachment(100, 0);
+ border.setLayoutData(borderFd);
+ PropsUi.setLook(border);
+
+ tree = new Tree(border, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+ tree.setHeaderVisible(false);
+ PropsUi.setLook(tree);
+ FormData treeFd = new FormData();
+ treeFd.left = new FormAttachment(0, 0);
+ treeFd.top = new FormAttachment(0, 0);
+ treeFd.right = new FormAttachment(100, 0);
+ treeFd.bottom = new FormAttachment(100, 0);
+ tree.setLayoutData(treeFd);
+
+ TreeMemory.addTreeListener(tree, treeMemoryName);
+
+ tree.addListener(SWT.MouseDown, this::onMouseDown);
+ tree.addListener(SWT.DefaultSelection, this::onDefaultSelection);
+ tree.addListener(SWT.MouseHover, this::onHover);
+ tree.addListener(SWT.MouseMove, this::onHover);
+ }
+
+ private void createContextMenu() {
+ Menu menu = new Menu(tree);
+ MenuItem favoriteItem = new MenuItem(menu, SWT.PUSH);
+ favoriteItem.addListener(SWT.Selection, e ->
toggleFavorite(selectedItem()));
+ tree.setMenu(menu);
+ tree.addListener(
+ SWT.MenuDetect,
+ event -> {
+ Item item = selectedItem();
+ if (item == null) {
+ event.doit = false;
+ return;
+ }
+ boolean favorite =
GuiActionFavorites.isFavorite(host.getPaletteKind(), item.pluginId());
+ favoriteItem.setText(
+ BaseMessages.getString(
+ PKG,
+ favorite ? "GraphPalette.Menu.RemoveFavorite" :
"GraphPalette.Menu.AddFavorite"));
+ });
+ }
+
+ private void createDragSource() {
+ DragSource dragSource = new DragSource(tree, DND.DROP_COPY);
+ dragSource.setTransfer(new Transfer[] {TextTransfer.getInstance()});
+ dragSource.addDragListener(
+ new DragSourceAdapter() {
+ @Override
+ public void dragStart(DragSourceEvent event) {
+ Item item = selectedItem();
+ if (item == null || altHeld) {
+ event.doit = false;
+ return;
+ }
+ installShiftKeyFilter();
+ setDragImage(event);
+ }
+
+ @Override
+ public void dragSetData(DragSourceEvent event) {
+ Item item = selectedItem();
+ if (item == null ||
!TextTransfer.getInstance().isSupportedType(event.dataType)) {
+ event.doit = false;
+ return;
+ }
+ event.data = ContextDialogPlacement.encode(item.actionId(),
shiftHeld);
+ }
+
+ @Override
+ public void dragFinished(DragSourceEvent event) {
+ removeShiftKeyFilter();
+ if (dragImage != null) {
+ dragImage.dispose();
+ dragImage = null;
+ }
+ }
+ });
+ }
+
+ private void setDragImage(DragSourceEvent event) {
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ event.image = GuiResource.getInstance().getImageHop();
+ return;
+ }
+ TreeItem[] selection = tree.getSelection();
+ if (selection == null || selection.length == 0) {
+ return;
+ }
+ Rectangle bounds = selection[0].getBounds();
+ int w = Math.max(1, bounds.width);
+ int h = Math.max(1, bounds.height);
+ try {
+ dragImage = new Image(getDisplay(), w, h);
+ GC gc = new GC(tree);
+ try {
+ gc.copyArea(dragImage, bounds.x, bounds.y);
+ } finally {
+ gc.dispose();
+ }
+ event.image = dragImage;
+ } catch (Exception e) {
+ // Fall back to the default drag image.
+ }
+ }
+
+ private void installShiftKeyFilter() {
+ removeShiftKeyFilter();
+ Display display = getDisplay();
+ shiftKeyFilter =
+ event -> {
+ if (event.keyCode == SWT.SHIFT) {
+ shiftHeld = event.type == SWT.KeyDown;
+ }
+ };
+ display.addFilter(SWT.KeyDown, shiftKeyFilter);
+ display.addFilter(SWT.KeyUp, shiftKeyFilter);
+ }
+
+ private void removeShiftKeyFilter() {
+ if (shiftKeyFilter == null) {
+ return;
+ }
+ Display display = getDisplay();
+ if (display != null && !display.isDisposed()) {
+ display.removeFilter(SWT.KeyDown, shiftKeyFilter);
+ display.removeFilter(SWT.KeyUp, shiftKeyFilter);
+ }
+ shiftKeyFilter = null;
+ }
+
+ private void onMouseDown(Event event) {
+ shiftHeld = (event.stateMask & SWT.SHIFT) != 0;
+ altHeld = (event.stateMask & SWT.ALT) != 0;
+ if (!altHeld || event.button != 1) {
+ return;
+ }
+ TreeItem treeItem = tree.getItem(new
org.eclipse.swt.graphics.Point(event.x, event.y));
+ Item item = itemOf(treeItem);
+ if (item != null) {
+ toggleFavorite(item);
+ }
+ }
+
+ private void onDefaultSelection(Event event) {
+ // Shift-double-click does not always change Tree.getSelection(); use the
item that was
+ // actually activated (event.item) so we add that plugin, not a previously
selected one.
+ TreeItem treeItem = event.item instanceof TreeItem ti ? ti : null;
+ Item item = itemOf(treeItem);
+ if (item == null) {
+ item = selectedItem();
+ }
+ if (item == null) {
+ return;
+ }
+ if (treeItem != null) {
+ tree.setSelection(treeItem);
+ }
+ if ((event.stateMask & SWT.ALT) != 0) {
+ toggleFavorite(item);
+ return;
+ }
+ boolean chain = (event.stateMask & SWT.SHIFT) != 0 || shiftHeld;
+ // Shift-double-click: let the graph place the new item in a row after the
last/selected
+ // one (null location). Plain double-click still uses the last canvas
click.
+ Point location = chain ? null : host.getPaletteDropLocation();
+ host.placePaletteAction(item.actionId(), location, chain);
+ }
+
+ private void onHover(Event event) {
+ TreeItem treeItem = tree.getItem(new
org.eclipse.swt.graphics.Point(event.x, event.y));
+ Item item = itemOf(treeItem);
+ if (item == null) {
+ tree.setToolTipText(null);
+ return;
+ }
+ boolean favorite = GuiActionFavorites.isFavorite(host.getPaletteKind(),
item.pluginId());
+
tree.setToolTipText(GuiActionFavorites.tooltipWithFavoriteHint(item.description(),
favorite));
+ }
+
+ private void toggleFavorite(Item item) {
+ if (item == null) {
+ return;
+ }
+ GuiActionFavorites.toggle(host.getPaletteKind(), item.pluginId());
+ host.persistFavoritesChange();
+ }
+
+ /** Rebuild from plugins the next time the tree is shown, or immediately
when already visible. */
+ public void refresh() {
+ dirty = true;
+ if (!isDisposed() && GraphPalette.isVisible()) {
+ rebuildTree();
+ }
+ }
+
+ public void ensurePopulated() {
+ if (isDisposed()) {
+ return;
+ }
+ if (dirty || !populated) {
+ rebuildTree();
+ }
+ }
+
+ private void asyncRefresh() {
+ if (isDisposed()) {
+ return;
+ }
+ getDisplay()
+ .asyncExec(
+ () -> {
+ if (!isDisposed()) {
+ refresh();
+ }
+ });
+ }
+
+ private void rebuildTree() {
+ if (tree == null || tree.isDisposed()) {
+ return;
+ }
+ model = GraphPaletteModel.fromPlugins(host.getPaletteKind());
+ String filter = filterText == null || filterText.isDisposed() ? "" :
filterText.getText();
+ List<Category> categories = model.filter(filter);
+ boolean filtering = StringUtils.isNotEmpty(filter);
+
+ tree.setRedraw(false);
+ try {
+ tree.removeAll();
+ GuiResource images = GuiResource.getInstance();
+ String favoritesName = GraphPaletteModel.favoritesCategoryName();
+ for (Category category : categories) {
+ TreeItem categoryItem = new TreeItem(tree, SWT.NONE);
+ categoryItem.setText(category.name());
+ if (favoritesName.equals(category.name())) {
+ categoryItem.setImage(images.getImage("ui/images/bookmark-add.svg"));
+ } else {
+ categoryItem.setImage(images.getImageFolder());
+ }
+ for (Item item : category.items()) {
+ TreeItem leaf = new TreeItem(categoryItem, SWT.NONE);
+ leaf.setText(item.name());
+ leaf.setImage(iconFor(item));
+ leaf.setData(item);
+ }
+ }
+ if (filtering) {
+ expandAll(true);
+ } else if (!populated) {
+ expandAll(true);
+ } else {
+ TreeMemory.setExpandedFromMemory(tree, treeMemoryName);
+ }
+ } finally {
+ tree.setRedraw(true);
+ }
+ populated = true;
+ dirty = false;
+ }
+
+ private Image iconFor(Item item) {
+ int size = ConstUi.SMALL_ICON_SIZE;
+ GuiResource images = GuiResource.getInstance();
+ if (host.getPaletteKind() == Kind.TRANSFORM) {
+ return images
+ .getSwtImageTransform(item.pluginId())
+ .getAsBitmapForSize(getDisplay(), size, size);
+ }
+ return
images.getSwtImageAction(item.pluginId()).getAsBitmapForSize(getDisplay(),
size, size);
+ }
+
+ private void expandAll(boolean expanded) {
+ if (tree == null || tree.isDisposed()) {
+ return;
+ }
+ tree.setRedraw(false);
+ try {
+ for (TreeItem item : tree.getItems()) {
+ expandTreeItem(item, expanded);
+ }
+ } finally {
+ tree.setRedraw(true);
+ }
+ }
+
+ private void expandTreeItem(TreeItem item, boolean expanded) {
+ item.setExpanded(expanded);
+ TreeMemory.getInstance().storeExpanded(treeMemoryName, item, expanded);
+ for (TreeItem child : item.getItems()) {
+ expandTreeItem(child, expanded);
+ }
+ }
+
+ public void clearFilter() {
+ if (filterText == null || filterText.isDisposed()) {
+ return;
+ }
+ if (StringUtils.isNotEmpty(filterText.getText())) {
+ filterText.setText("");
+ } else {
+ rebuildTree();
+ }
+ filterText.setFocus();
+ }
+
+ private Item selectedItem() {
+ if (tree == null || tree.isDisposed() || tree.getSelectionCount() != 1) {
+ return null;
+ }
+ return itemOf(tree.getSelection()[0]);
+ }
+
+ private static Item itemOf(TreeItem treeItem) {
+ if (treeItem == null) {
+ return null;
+ }
+ Object data = treeItem.getData();
+ return data instanceof Item item ? item : null;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/palette/IGraphPaletteHost.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/IGraphPaletteHost.java
new file mode 100644
index 0000000000..4e1e86894d
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/palette/IGraphPaletteHost.java
@@ -0,0 +1,51 @@
+/*
+ * 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.palette;
+
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+
+/** Pipeline or workflow graph that hosts a {@link GraphPaletteTree}. */
+public interface IGraphPaletteHost {
+
+ HopGui getHopGui();
+
+ /** Stable id for event-listener registration (typically the graph UUID). */
+ String getPaletteHostId();
+
+ GuiActionFavorites.Kind getPaletteKind();
+
+ /**
+ * Place a create-transform / create-action at {@code graphLocation}.
+ *
+ * @param graphLocation canvas graph coordinates, or {@code null} to place
in a row after the
+ * chain source (Shift-double-click)
+ * @param chainHop when true, also hop from the selected item, or the last
chained item
+ */
+ boolean placePaletteAction(String actionId, Point graphLocation, boolean
chainHop);
+
+ /** Last canvas click in graph coordinates, or the visible canvas center. */
+ Point getPaletteDropLocation();
+
+ /** Show or hide the palette sash from the persisted {@link
GraphPalette#isVisible()} flag. */
+ void applyPaletteVisibility();
+
+ /** Persist a favorite toggle and notify other open palettes. */
+ void persistFavoritesChange();
+}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/palette/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/palette/messages/messages_en_US.properties
new file mode 100644
index 0000000000..4e61acff2d
--- /dev/null
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/palette/messages/messages_en_US.properties
@@ -0,0 +1,26 @@
+#
+# 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.
+#
+
+GraphPalette.Toolbar.Show.Tooltip=Show the transform/action palette tree
+GraphPalette.Toolbar.Hide.Tooltip=Hide the transform/action palette tree
+GraphPalette.Filter.Transforms.Placeholder=Filter transforms
+GraphPalette.Filter.Actions.Placeholder=Filter actions
+GraphPalette.Toolbar.ExpandAll.Tooltip=Expand all categories
+GraphPalette.Toolbar.CollapseAll.Tooltip=Collapse all categories
+GraphPalette.Toolbar.ClearFilter.Tooltip=Clear filter
+GraphPalette.Menu.AddFavorite=Add to favorites
+GraphPalette.Menu.RemoveFavorite=Remove from favorites
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
index 14e34093e3..536003292f 100644
--- a/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
@@ -110,6 +110,56 @@ class HopGuiKeyHandlerTest {
}
}
+ /**
+ * Pipeline graphs bind Space to "show output fields". That must not eat
Space in a filter Text
+ * (the palette tree search box).
+ */
+ public static class SpaceGraph {
+ public int spaces;
+ public int letters;
+
+ @GuiKeyboardShortcut(key = ' ')
+ @GuiOsxKeyboardShortcut(key = ' ')
+ public void showOutputFields() {
+ spaces++;
+ }
+
+ @GuiKeyboardShortcut(key = 'z')
+ @GuiOsxKeyboardShortcut(key = 'z')
+ public void openReferencedObject() {
+ letters++;
+ }
+ }
+
+ @Test
+ void spaceAndLettersAreLeftToTextWidgets() {
+ SpaceGraph graph = new SpaceGraph();
+ registerShortcutsLikeHopGuiEnvironment(SpaceGraph.class);
+
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ keyHandler.addParentObjectToHandle(graph);
+ try {
+ KeyEvent spaceInText = keyEvent(mock(Text.class), SWT.SPACE, SWT.NONE);
+ spaceInText.character = ' ';
+ keyHandler.keyPressed(spaceInText);
+ assertEquals(0, graph.spaces, "Space must type into text widgets, not
run graph shortcuts");
+ assertTrue(spaceInText.doit, "Space must not be consumed when a text
widget has focus");
+
+ KeyEvent letterInText = keyEvent(mock(Text.class), 'z', SWT.NONE);
+ letterInText.character = 'z';
+ keyHandler.keyPressed(letterInText);
+ assertEquals(
+ 0, graph.letters, "Letters must type into text widgets, not run
graph shortcuts");
+
+ KeyEvent spaceOnCanvas = canvasKey(SWT.SPACE, SWT.NONE);
+ spaceOnCanvas.character = ' ';
+ keyHandler.keyPressed(spaceOnCanvas);
+ assertEquals(1, graph.spaces, "Space on the canvas still runs the graph
shortcut");
+ } finally {
+ keyHandler.removeParentObjectToHandle(graph);
+ }
+ }
+
@Test
void arrowKeysAreLeftToTablesAndTrees() {
NavigationGraph graph = new NavigationGraph();
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacementTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacementTest.java
new file mode 100644
index 0000000000..7911496b9e
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/ContextDialogPlacementTest.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.context;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.junit.jupiter.api.Test;
+
+class ContextDialogPlacementTest {
+
+ @Test
+ void encodeDecodeCreateAction() {
+ GuiAction action =
+ new GuiAction(
+ "pipeline-graph-create-transform-Dummy",
+ GuiActionType.Create,
+ "Dummy",
+ "desc",
+ null,
+ (a, b, c) -> {});
+ String payload = ContextDialogPlacement.encode(action);
+ assertTrue(ContextDialogPlacement.isPlacementPayload(payload));
+ assertFalse(ContextDialogPlacement.isChainPayload(payload));
+ assertEquals(action.getId(),
ContextDialogPlacement.decodeActionId(payload));
+ }
+
+ @Test
+ void encodeDecodeChainPayload() {
+ String payload =
ContextDialogPlacement.encode("pipeline-graph-create-transform-Dummy", true);
+ assertTrue(ContextDialogPlacement.isPlacementPayload(payload));
+ assertTrue(ContextDialogPlacement.isChainPayload(payload));
+ assertEquals(
+ "pipeline-graph-create-transform-Dummy",
ContextDialogPlacement.decodeActionId(payload));
+ }
+
+ @Test
+ void ignoresUnrelatedText() {
+ assertFalse(ContextDialogPlacement.isPlacementPayload("hello"));
+ assertNull(ContextDialogPlacement.decodeActionId("hello"));
+ }
+}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModelTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModelTest.java
new file mode 100644
index 0000000000..dd3a302b71
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteModelTest.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.hopgui.palette;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Category;
+import org.apache.hop.ui.hopgui.palette.GraphPaletteModel.Item;
+import org.junit.jupiter.api.Test;
+
+class GraphPaletteModelTest {
+
+ private static Item item(String id, String name, String category, String...
keywords) {
+ return new Item(
+ id,
+ GuiActionFavorites.createId(GuiActionFavorites.Kind.TRANSFORM, id),
+ name,
+ category,
+ name + " description",
+ "ui/images/" + id + ".svg",
+ List.of(keywords));
+ }
+
+ @Test
+ void favoritesCategoryIsFirstAndKeepsStoredOrder() {
+ Item csv = item("CsvInput", "CSV file input", "Input");
+ Item table = item("TableInput", "Table input", "Input");
+ Item text = item("TextFileInput", "Text file input", "Input");
+ Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+
+ GraphPaletteModel model =
+ GraphPaletteModel.fromItems(
+ List.of(csv, table, text, dummy), List.of("TextFileInput",
"CsvInput"), "Favorites");
+
+ List<Category> categories = model.getCategories();
+ assertEquals("Favorites", categories.get(0).name());
+ assertEquals(List.of("TextFileInput", "CsvInput"),
pluginIds(categories.get(0)));
+ assertEquals("Flow", categories.get(1).name());
+ assertEquals("Input", categories.get(2).name());
+ assertEquals(
+ List.of("CSV file input", "Table input", "Text file input"),
names(categories.get(2)));
+ }
+
+ @Test
+ void emptyFavoritesOmitsTheCategory() {
+ Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+ GraphPaletteModel model = GraphPaletteModel.fromItems(List.of(dummy),
List.of(), "Favorites");
+ assertEquals(1, model.getCategories().size());
+ assertEquals("Flow", model.getCategories().get(0).name());
+ }
+
+ @Test
+ void unknownFavoriteIdsAreIgnored() {
+ Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+ GraphPaletteModel model =
+ GraphPaletteModel.fromItems(List.of(dummy), List.of("DoesNotExist"),
"Favorites");
+ assertEquals(1, model.getCategories().size());
+ assertEquals("Flow", model.getCategories().get(0).name());
+ }
+
+ @Test
+ void itemsAndCategoriesAreAlphabetical() {
+ Item zebra = item("Zebra", "Zebra", "Zeta");
+ Item alpha = item("Alpha", "Alpha", "Zeta");
+ Item mid = item("Mid", "Mid", "Alpha");
+ GraphPaletteModel model =
+ GraphPaletteModel.fromItems(List.of(zebra, alpha, mid), List.of(),
"Favorites");
+ assertEquals(
+ List.of("Alpha", "Zeta"),
model.getCategories().stream().map(Category::name).toList());
+ assertEquals(List.of("Alpha", "Zebra"),
names(model.getCategories().get(1)));
+ }
+
+ @Test
+ void filterDropsEmptyCategoriesAndMatchesNameKeywordAndCategory() {
+ Item csv = item("CsvInput", "CSV file input", "Input", "file");
+ Item table = item("TableInput", "Table input", "Input");
+ Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+ GraphPaletteModel model =
+ GraphPaletteModel.fromItems(List.of(csv, table, dummy), List.of(),
"Favorites");
+
+ List<Category> csvHit = model.filter("csv");
+ assertEquals(1, csvHit.size());
+ assertEquals("Input", csvHit.get(0).name());
+ assertEquals(List.of("CSV file input"), names(csvHit.get(0)));
+
+ List<Category> fileHit = model.filter("file");
+ assertEquals(1, fileHit.size());
+ assertEquals(List.of("CSV file input"), names(fileHit.get(0)));
+
+ List<Category> flowHit = model.filter("flow");
+ assertEquals(1, flowHit.size());
+ assertEquals("Flow", flowHit.get(0).name());
+ assertEquals(List.of("Dummy (do nothing)"), names(flowHit.get(0)));
+
+ assertTrue(model.filter("zzz-no-such-plugin").isEmpty());
+ }
+
+ @Test
+ void blankFilterReturnsEveryCategory() {
+ Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+ GraphPaletteModel model = GraphPaletteModel.fromItems(List.of(dummy),
List.of(), "Favorites");
+ assertEquals(model.getCategories(), model.filter(""));
+ assertEquals(model.getCategories(), model.filter(null));
+ }
+
+ @Test
+ void actionIdUsesCreatePrefix() {
+ Item dummy = item("Dummy", "Dummy (do nothing)", "Flow");
+ assertEquals("pipeline-graph-create-transform-Dummy", dummy.actionId());
+ }
+
+ private static List<String> pluginIds(Category category) {
+ return category.items().stream().map(Item::pluginId).toList();
+ }
+
+ private static List<String> names(Category category) {
+ return category.items().stream().map(Item::name).toList();
+ }
+}
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTest.java
new file mode 100644
index 0000000000..27a6fc288c
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/palette/GraphPaletteTest.java
@@ -0,0 +1,59 @@
+/*
+ * 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.palette;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.config.HopConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GraphPaletteTest {
+
+ private String saved;
+
+ @BeforeEach
+ void setUp() {
+ saved = HopConfig.getGuiProperty(GraphPalette.CONFIG_KEY);
+ HopConfig.readGuiProperties().remove(GraphPalette.CONFIG_KEY);
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (saved == null) {
+ HopConfig.readGuiProperties().remove(GraphPalette.CONFIG_KEY);
+ } else {
+ HopConfig.setGuiProperty(GraphPalette.CONFIG_KEY, saved);
+ }
+ }
+
+ @Test
+ void hiddenByDefault() {
+ assertFalse(GraphPalette.isVisible());
+ }
+
+ @Test
+ void setVisibleUpdatesTheFlagWithoutRequiringYOnMissingKey() {
+ HopConfig.setGuiProperty(GraphPalette.CONFIG_KEY, "Y");
+ assertTrue(GraphPalette.isVisible());
+ HopConfig.setGuiProperty(GraphPalette.CONFIG_KEY, "N");
+ assertFalse(GraphPalette.isVisible());
+ }
+}