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 e049f5f655 fix naviagtion on canvas and some shortcuts, fixes #7848
(#7895)
e049f5f655 is described below
commit e049f5f6558c88aa0014788cb8ce9f1b512df622
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Aug 13 09:05:50 2026 +0200
fix naviagtion on canvas and some shortcuts, fixes #7848 (#7895)
---
.../apache/hop/core/gui/plugin/GuiRegistry.java | 5 +-
.../hop/core/gui/plugin/key/KeyboardShortcut.java | 7 +-
.../org/apache/hop/ui/hopgui/HopWebEntryPoint.java | 21 +-
.../apache/hop/ui/hopgui/HopWebEntryPointTest.java | 30 +++
.../main/java/org/apache/hop/ui/core/PropsUi.java | 7 +
.../main/java/org/apache/hop/ui/hopgui/HopGui.java | 17 +-
.../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java | 164 ++++++++++++-
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 49 ++++
.../ui/hopgui/file/shared/HopGuiAbstractGraph.java | 22 ++
.../hopgui/file/workflow/HopGuiWorkflowGraph.java | 46 ++++
.../perspective/execution/DragViewZoomBase.java | 88 ++++++-
.../apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java | 268 +++++++++++++++++++++
12 files changed, 691 insertions(+), 33 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
index e35f464023..22b3f5504c 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
@@ -552,6 +552,7 @@ public class GuiRegistry {
List<KeyboardShortcut> shortcuts =
shortCutsMap.computeIfAbsent(guiPluginClassName, k -> new
ArrayList<>());
KeyboardShortcut keyboardShortCut = new KeyboardShortcut(shortcut, method);
+ keyboardShortCut.setParentClassName(guiPluginClassName);
shortcuts.add(keyboardShortCut);
}
@@ -559,7 +560,9 @@ public class GuiRegistry {
String parentClassName, Method parentMethod, GuiOsxKeyboardShortcut
shortcut) {
List<KeyboardShortcut> shortcuts =
shortCutsMap.computeIfAbsent(parentClassName, k -> new ArrayList<>());
- shortcuts.add(new KeyboardShortcut(shortcut, parentMethod));
+ KeyboardShortcut keyboardShortCut = new KeyboardShortcut(shortcut,
parentMethod);
+ keyboardShortCut.setParentClassName(parentClassName);
+ shortcuts.add(keyboardShortCut);
}
/**
diff --git
a/core/src/main/java/org/apache/hop/core/gui/plugin/key/KeyboardShortcut.java
b/core/src/main/java/org/apache/hop/core/gui/plugin/key/KeyboardShortcut.java
index e876b272dd..bff53911b9 100644
---
a/core/src/main/java/org/apache/hop/core/gui/plugin/key/KeyboardShortcut.java
+++
b/core/src/main/java/org/apache/hop/core/gui/plugin/key/KeyboardShortcut.java
@@ -31,7 +31,12 @@ public class KeyboardShortcut {
/** If true, shortcut works from anywhere (e.g. activate perspective, toggle
terminal). */
private boolean global;
- /** Class that declares the shortcut (where it should be triggered). */
+ /**
+ * Class the shortcut is triggered on. It defaults to the class declaring
the annotated method but
+ * is set to the GUI plugin class when the shortcut is registered: a
shortcut method can be
+ * inherited from a base class (the canvas navigation keys are declared on
DragViewZoomBase and
+ * inherited by the pipeline and workflow graphs).
+ */
private String parentClassName;
private String parentMethodName;
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 352b48f105..499ace78b9 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
@@ -59,11 +59,12 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
Set.of("CTRL+C", "CTRL+V", "CTRL+X");
/**
- * Bare navigation keys used for caret movement and selection in text fields
(and tree widgets).
- * They may stay in {@code ACTIVE_KEYS} so canvas pan shortcuts still reach
the server when focus
- * is on the graph, but must not be in {@code CANCEL_KEYS} or the browser
never moves the caret
- * (see issue #7833). Modifier combinations (CTRL+ARROW_*, ALT+ARROW_*, …)
are separate RAP keys
- * and remain cancelled when registered as application shortcuts.
+ * Navigation keys used for caret movement and selection in text fields (and
tree widgets), both
+ * bare and with SHIFT held down to extend the selection. They may stay in
{@code ACTIVE_KEYS} so
+ * the canvas navigation shortcuts still reach the server when focus is on
the graph, but must not
+ * be in {@code CANCEL_KEYS} or the browser never moves the caret (see issue
#7833). Modifier
+ * combinations with CTRL or ALT are separate RAP keys and remain cancelled
when registered as
+ * application shortcuts.
*/
private static final Set<String> NATIVE_TEXT_NAVIGATION_KEYS =
Set.of(
@@ -74,7 +75,15 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
"HOME",
"END",
"PAGE_UP",
- "PAGE_DOWN");
+ "PAGE_DOWN",
+ "SHIFT+ARROW_UP",
+ "SHIFT+ARROW_DOWN",
+ "SHIFT+ARROW_LEFT",
+ "SHIFT+ARROW_RIGHT",
+ "SHIFT+HOME",
+ "SHIFT+END",
+ "SHIFT+PAGE_UP",
+ "SHIFT+PAGE_DOWN");
/** Audit group/type/name for Hop Web theme preference (per-user in audit
folder). */
public static final String AUDIT_GROUP_HOP_WEB = "hop-web";
diff --git
a/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
index 60c65c1c7e..022dbef3ab 100644
--- a/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
+++ b/rap/src/test/java/org/apache/hop/ui/hopgui/HopWebEntryPointTest.java
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.when;
import java.util.Arrays;
import org.apache.hop.core.gui.plugin.key.KeyboardShortcut;
+import org.eclipse.swt.SWT;
import org.junit.jupiter.api.Test;
class HopWebEntryPointTest {
@@ -60,6 +61,35 @@ class HopWebEntryPointTest {
assertArrayEquals(new String[] {"CTRL+ARROW_LEFT", "CTRL+S"},
cancelledShortcuts);
}
+ @Test
+ void preservesShiftNavigationKeysAsActiveOnly() {
+ // SHIFT+arrow moves the selection on the canvas but still extends the
selection in text
+ // fields, so the browser must keep handling it.
+ String[] activeShortcuts = {
+ "SHIFT+ARROW_LEFT",
+ "SHIFT+ARROW_RIGHT",
+ "SHIFT+ARROW_UP",
+ "SHIFT+ARROW_DOWN",
+ "SHIFT+HOME",
+ "SHIFT+END",
+ "CTRL+SHIFT+ARROW_UP",
+ "CTRL+S"
+ };
+
+ String[] cancelledShortcuts =
HopWebEntryPoint.buildCancelledKeyboardShortcuts(activeShortcuts);
+
+ assertArrayEquals(new String[] {"CTRL+SHIFT+ARROW_UP", "CTRL+S"},
cancelledShortcuts);
+ }
+
+ @Test
+ void mapsShiftArrowShortcutToRapFormat() {
+ KeyboardShortcut shortcut = mock(KeyboardShortcut.class);
+ when(shortcut.getKeyCode()).thenReturn(SWT.ARROW_LEFT);
+ when(shortcut.isShift()).thenReturn(true);
+
+ assertEquals("SHIFT+ARROW_LEFT", new
HopWebEntryPoint().convertToRapFormat(shortcut));
+ }
+
@Test
void removesDuplicateCancelledShortcuts() {
String[] cancelledShortcuts =
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index cd1119896b..62ad5d857a 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -34,6 +34,7 @@ import org.apache.hop.ui.core.gui.GuiResource;
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.HopGuiKeyHandler;
import org.apache.hop.ui.hopgui.TextSizeUtilFacade;
import org.apache.hop.ui.util.EnvironmentUtils;
import org.eclipse.swt.SWT;
@@ -774,6 +775,12 @@ public class PropsUi extends Props {
}
public static void setLook(final Widget widget, int style) {
+ // Handle the keyboard shortcuts of widgets that are created after their
shell was set up, e.g.
+ // when a metadata editor rebuilds a section. In Hop Web the key listener
has to be there when
+ // the widget is rendered or RAP never sends its key events to the server.
+ //
+ HopGuiKeyHandler.getInstance().attachTo(widget);
+
if (EnvironmentUtils.getInstance().isWeb()) {
setLookOnWeb(widget, style);
return;
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index 8ba94c6e95..a5654476df 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -1969,7 +1969,20 @@ public class HopGui
return;
}
- if (control.getData("HOP_TERMINAL_WIDGET") == Boolean.TRUE) {
+ // Widgets that this shell creates later on (a metadata editor rebuilding
a section, ...) are
+ // covered when they get the focus.
+ //
+ keyHandler.addHandledShell(display, control.getShell());
+
+ addKeyboardShortcutListeners(control, keyHandler);
+ }
+
+ private void addKeyboardShortcutListeners(Control control, HopGuiKeyHandler
keyHandler) {
+ if (control == null || control.isDisposed()) {
+ return;
+ }
+
+ if (control.getData(HopGuiKeyHandler.HOP_TERMINAL_WIDGET) == Boolean.TRUE)
{
return;
}
@@ -1980,7 +1993,7 @@ public class HopGui
//
if (control instanceof Composite compositeControl) {
for (Control child : compositeControl.getChildren()) {
- replaceKeyboardShortcutListeners(child, keyHandler);
+ addKeyboardShortcutListeners(child, keyHandler);
}
}
}
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 9d2d467c75..8b6030100d 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
@@ -25,6 +25,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import org.apache.hop.core.Const;
import org.apache.hop.core.gui.plugin.GuiRegistry;
import org.apache.hop.core.gui.plugin.key.KeyboardShortcut;
@@ -35,13 +36,27 @@ import org.eclipse.swt.SWTException;
import org.eclipse.swt.custom.CCombo;
import org.eclipse.swt.events.KeyAdapter;
import org.eclipse.swt.events.KeyEvent;
+import org.eclipse.swt.events.KeyListener;
import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableItem;
import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
import org.eclipse.swt.widgets.Widget;
public class HopGuiKeyHandler extends KeyAdapter {
+ /** Data key marking the terminal widget, which handles all keys itself. */
+ public static final String HOP_TERMINAL_WIDGET = "HOP_TERMINAL_WIDGET";
+
+ /** Widget classes that pass their key listeners on to a widget inside them.
*/
+ private static final Map<Class<?>, Boolean> DELEGATING_KEY_LISTENERS = new
ConcurrentHashMap<>();
+
private static HopGuiKeyHandler singleton;
public Set<Object> parentObjects;
@@ -49,6 +64,15 @@ public class HopGuiKeyHandler extends KeyAdapter {
/** Parent -> Control (e.g. shell) so we try that parent when focus is in
its window. */
private final Map<Object, Control> parentToControl = new HashMap<>();
+ /** Shells this handler covers, used to also cover the widgets they create
later on. */
+ private final Set<Shell> handledShells = new HashSet<>();
+
+ /**
+ * Displays with a focus filter. This handler is a singleton for the whole
process while Hop Web
+ * has a display per session, so the filter is installed once per display.
+ */
+ private final Set<Display> filteredDisplays = new HashSet<>();
+
private HopGuiKeyHandler() {
this.parentObjects = new HashSet<>();
}
@@ -77,6 +101,96 @@ public class HopGuiKeyHandler extends KeyAdapter {
parentToControl.remove(parentObject);
}
+ /**
+ * Start handling the keys of a shell, including those of the widgets it
creates later on.
+ *
+ * <p>The handler is a key listener that is attached to every widget of a
shell. Widgets created
+ * after that, when a dialog or metadata editor rebuilds part of its content
(e.g. the OAuth 2
+ * fields of a REST connection when the authentication type changes), have
no key listener of
+ * their own, so keyboard shortcuts like Ctrl+S do nothing while such a
widget has the focus.
+ * {@link #attachTo(Widget)} handles those, called from {@code
PropsUi.setLook()} when the widget
+ * is created and, on the desktop, when a widget receives the focus.
+ *
+ * @param display the display to listen to for focus changes
+ * @param shell the shell whose widgets are handled
+ */
+ public void addHandledShell(Display display, Shell shell) {
+ if (shell == null || shell.isDisposed() || !handledShells.add(shell)) {
+ return;
+ }
+ shell.addDisposeListener(e -> handledShells.remove(shell));
+
+ // Safety net for widgets that are created without PropsUi.setLook(). Hop
Web has no use for it:
+ // RAP does not fire focus events for a focus change made in the browser.
+ //
+ if (display != null && !display.isDisposed() &&
filteredDisplays.add(display)) {
+ display.addFilter(SWT.FocusIn, event -> attachTo(event.widget));
+ display.addListener(SWT.Dispose, e -> filteredDisplays.remove(display));
+ }
+ }
+
+ /**
+ * Attach this handler to a widget of a shell we handle, so its keyboard
shortcuts work.
+ *
+ * <p>This has to happen when the widget is created: RAP only sends the key
events of a widget to
+ * the server when that widget has a key listener at the time it is
rendered, so attaching later
+ * is too late for Hop Web.
+ *
+ * @param widget the widget to handle the keys of
+ * @return true if the handler was attached to the widget
+ */
+ public boolean attachTo(Widget widget) {
+ if (!(widget instanceof Control control) || control.isDisposed()) {
+ return false;
+ }
+ try {
+ if (!handledShells.contains(control.getShell())
+ || isInTerminalWidget(control)
+ || delegatesKeyListeners(control)) {
+ return false;
+ }
+ // Removing first avoids handling the key twice for widgets that already
have the handler
+ control.removeKeyListener(this);
+ control.addKeyListener(this);
+ return true;
+ } catch (SWTException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Composite widgets like TextVar and ComboVar pass the key listeners they
get on to the widget
+ * inside them. They style themselves before creating that widget, so it is
not always there yet,
+ * and it gets the handler through its own {@code PropsUi.setLook()} call
anyway.
+ */
+ private static boolean delegatesKeyListeners(Control control) {
+ if (!(control instanceof Composite)) {
+ return false;
+ }
+ return DELEGATING_KEY_LISTENERS.computeIfAbsent(
+ control.getClass(),
+ widgetClass -> {
+ try {
+ Method method = widgetClass.getMethod("addKeyListener",
KeyListener.class);
+ return !Control.class.equals(method.getDeclaringClass());
+ } catch (NoSuchMethodException e) {
+ return Boolean.FALSE;
+ }
+ });
+ }
+
+ /** The terminal widget and everything in it handles all keys itself. */
+ private static boolean isInTerminalWidget(Control control) {
+ Control current = control;
+ while (current != null) {
+ if (current.getData(HOP_TERMINAL_WIDGET) == Boolean.TRUE) {
+ return true;
+ }
+ current = current.getParent();
+ }
+ return false;
+ }
+
@Override
public void keyPressed(KeyEvent event) {
if (!event.doit) {
@@ -92,6 +206,13 @@ public class HopGuiKeyHandler extends KeyAdapter {
return;
}
+ // Same for tables, trees and lists: they use the arrow keys to move
through their rows. Those
+ // widgets also live inside the pipeline and workflow graph (log, preview
and result tabs) where
+ // the arrow keys navigate the canvas.
+ if (isRowNavigationWidget(event.widget) && isCaretNavigationKey(event)) {
+ return;
+ }
+
List<Object> orderedParents = getParentObjectsInContextOrder(event.widget);
for (Object parentObject : orderedParents) {
List<KeyboardShortcut> shortcuts =
@@ -283,6 +404,17 @@ public class HopGuiKeyHandler extends KeyAdapter {
|| isStyledText(widget);
}
+ /**
+ * Returns true if the widget is a Table, Tree or List: they navigate rows
with the arrow keys.
+ */
+ private static boolean isRowNavigationWidget(Widget widget) {
+ return widget instanceof Table
+ || widget instanceof Tree
+ || widget instanceof TableItem
+ || widget instanceof TreeItem
+ || widget instanceof org.eclipse.swt.widgets.List;
+ }
+
/**
* 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
@@ -298,20 +430,26 @@ public class HopGuiKeyHandler extends KeyAdapter {
if (event.keyCode == SWT.DEL || event.character == SWT.BS) {
return true;
}
- // Caret movement / selection: leave for the widget unless an app modifier
is held
- boolean hasAppModifier = (event.stateMask & (SWT.CONTROL | SWT.COMMAND |
SWT.ALT)) != 0;
- if (!hasAppModifier) {
- int keyCode = event.keyCode & SWT.KEY_MASK;
- return keyCode == SWT.ARROW_LEFT
- || keyCode == SWT.ARROW_RIGHT
- || keyCode == SWT.ARROW_UP
- || keyCode == SWT.ARROW_DOWN
- || keyCode == SWT.HOME
- || keyCode == SWT.END
- || keyCode == SWT.PAGE_UP
- || keyCode == SWT.PAGE_DOWN;
+ return isCaretNavigationKey(event);
+ }
+
+ /**
+ * Caret and row movement keys: the arrows, home/end and page up/down
without CTRL/CMD/ALT. SHIFT
+ * alone is allowed so extending a selection stays in the widget as well.
+ */
+ private static boolean isCaretNavigationKey(KeyEvent event) {
+ if ((event.stateMask & (SWT.CONTROL | SWT.COMMAND | SWT.ALT)) != 0) {
+ return false;
}
- return false;
+ int keyCode = event.keyCode & SWT.KEY_MASK;
+ return keyCode == SWT.ARROW_LEFT
+ || keyCode == SWT.ARROW_RIGHT
+ || keyCode == SWT.ARROW_UP
+ || keyCode == SWT.ARROW_DOWN
+ || keyCode == SWT.HOME
+ || keyCode == SWT.END
+ || keyCode == SWT.PAGE_UP
+ || keyCode == SWT.PAGE_DOWN;
}
/**
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 1f6e04f19d..b7e1d85029 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
@@ -2477,6 +2477,55 @@ public class HopGuiPipelineGraph extends
HopGuiAbstractGraph
}
}
+ /** Move the selected transforms and notes with the arrow keys, as a single
undo action. */
+ @Override
+ protected boolean nudgeSelectedElements(int dx, int dy) {
+ List<TransformMeta> transforms = pipelineMeta.getSelectedTransforms();
+ List<NotePadMeta> notes = pipelineMeta.getSelectedNotes();
+ if (Utils.isEmpty(transforms) && Utils.isEmpty(notes)) {
+ return false;
+ }
+
+ Point[] transformsBefore = captureLocations(transforms);
+ Point[] notesBefore = captureNoteLocations(notes);
+
+ moveSelected(dx, dy);
+
+ Point[] transformsAfter = captureLocations(transforms);
+ Point[] notesAfter = captureNoteLocations(notes);
+ if (Arrays.equals(transformsBefore, transformsAfter)
+ && Arrays.equals(notesBefore, notesAfter)) {
+ // Nothing moved: the selection is up against the top or left side of
the canvas.
+ return true;
+ }
+
+ // Record notes first, then transforms, linked into a single undo action
(nextAlso).
+ boolean also = false;
+ if (!Utils.isEmpty(notes)) {
+ also = !Utils.isEmpty(transforms);
+ hopGui.undoDelegate.addUndoPosition(
+ pipelineMeta,
+ notes.toArray(new NotePadMeta[0]),
+ pipelineMeta.getNoteIndexes(notes),
+ notesBefore,
+ notesAfter,
+ also);
+ }
+ if (!Utils.isEmpty(transforms)) {
+ hopGui.undoDelegate.addUndoPosition(
+ pipelineMeta,
+ transforms.toArray(new TransformMeta[0]),
+ pipelineMeta.getTransformIndexes(transforms),
+ transformsBefore,
+ transformsAfter,
+ also);
+ }
+
+ pipelineMeta.setChanged();
+ updateGui();
+ return true;
+ }
+
private void addCandidateAsHop(int mouseX, int mouseY) {
if (candidate == null) {
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
index 68f7ac10e8..1e973d7ef8 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/shared/HopGuiAbstractGraph.java
@@ -153,6 +153,28 @@ public abstract class HopGuiAbstractGraph extends
DragViewZoomBase
toolTip.setVisible(true);
}
+ /**
+ * On the pipeline and workflow canvas the arrow keys move the selected
transforms, actions and
+ * notes. Only when nothing is selected they pan the view.
+ */
+ @Override
+ protected boolean moveSelectionWithArrowKey(int dx, int dy, boolean
largeStep) {
+ int step = Math.max(1, PropsUi.getInstance().getCanvasGridSize());
+ if (largeStep) {
+ step *= LARGE_STEP_FACTOR;
+ }
+ return nudgeSelectedElements(dx * step, dy * step);
+ }
+
+ /**
+ * Move the selected elements on the canvas over the given distance, as one
undo action.
+ *
+ * @param dx the horizontal distance in graph coordinates
+ * @param dy the vertical distance in graph coordinates
+ * @return true if elements were selected and moved, false if there was
nothing to move
+ */
+ protected abstract boolean nudgeSelectedElements(int dx, int dy);
+
public abstract SnapAllignDistribute createSnapAlignDistribute();
@Override
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 3d0d7517ad..713ce64efa 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
@@ -3613,6 +3613,52 @@ public class HopGuiWorkflowGraph extends
HopGuiAbstractGraph
}
}
+ /** Move the selected actions and notes with the arrow keys, as a single
undo action. */
+ @Override
+ protected boolean nudgeSelectedElements(int dx, int dy) {
+ List<ActionMeta> actions = workflowMeta.getSelectedActions();
+ List<NotePadMeta> notes = workflowMeta.getSelectedNotes();
+ if (Utils.isEmpty(actions) && Utils.isEmpty(notes)) {
+ return false;
+ }
+
+ Point[] actionsBefore = captureLocations(actions);
+ Point[] notesBefore = captureNoteLocations(notes);
+
+ moveSelected(dx, dy);
+
+ Point[] actionsAfter = captureLocations(actions);
+ Point[] notesAfter = captureNoteLocations(notes);
+ if (Arrays.equals(actionsBefore, actionsAfter) &&
Arrays.equals(notesBefore, notesAfter)) {
+ // Nothing moved: the selection is up against the top or left side of
the canvas.
+ return true;
+ }
+
+ // Record notes first, then actions, linked into a single undo action
(nextAlso).
+ boolean also = false;
+ if (!Utils.isEmpty(notes)) {
+ also = !Utils.isEmpty(actions);
+ addUndoPosition(
+ notes.toArray(new NotePadMeta[0]),
+ workflowMeta.getNoteIndexes(notes),
+ notesBefore,
+ notesAfter,
+ also);
+ }
+ if (!Utils.isEmpty(actions)) {
+ addUndoPosition(
+ actions.toArray(new ActionMeta[0]),
+ workflowMeta.getActionIndexes(actions),
+ actionsBefore,
+ actionsAfter,
+ also);
+ }
+
+ workflowMeta.setChanged();
+ updateGui();
+ return true;
+ }
+
private void modalMessageDialog(String title, String message, int swtFlags) {
MessageBox messageBox = new MessageBox(hopShell(), swtFlags);
messageBox.setMessage(message);
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
index 086b192646..633bc9665c 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
@@ -30,6 +30,15 @@ import org.eclipse.swt.widgets.Canvas;
import org.eclipse.swt.widgets.Composite;
public abstract class DragViewZoomBase extends Composite {
+
+ /** The distance the view pans for one arrow key press, in graph coordinates
at 100% zoom. */
+ private static final double PAN_STEP = 20;
+
+ /**
+ * How much larger a step is when SHIFT is held down, both for panning and
for moving elements.
+ */
+ protected static final int LARGE_STEP_FACTOR = 5;
+
protected Canvas canvas;
protected DPoint offset;
protected Point maximum;
@@ -98,35 +107,94 @@ public abstract class DragViewZoomBase extends Composite {
@GuiKeyboardShortcut(key = SWT.ARROW_LEFT)
@GuiOsxKeyboardShortcut(key = SWT.ARROW_LEFT)
public void viewLeft() {
- offset.x += 15 * magnification * PropsUi.getNativeZoomFactor();
- validateOffset();
- redraw();
+ arrowKey(-1, 0, false);
}
@GuiKeyboardShortcut(key = SWT.ARROW_RIGHT)
@GuiOsxKeyboardShortcut(key = SWT.ARROW_RIGHT)
public void viewRight() {
- offset.x -= 15 * magnification * PropsUi.getNativeZoomFactor();
- validateOffset();
- redraw();
+ arrowKey(1, 0, false);
}
@GuiKeyboardShortcut(key = SWT.ARROW_UP)
@GuiOsxKeyboardShortcut(key = SWT.ARROW_UP)
public void viewUp() {
- offset.y += 15 * magnification * PropsUi.getNativeZoomFactor();
- validateOffset();
- redraw();
+ arrowKey(0, -1, false);
}
@GuiKeyboardShortcut(key = SWT.ARROW_DOWN)
@GuiOsxKeyboardShortcut(key = SWT.ARROW_DOWN)
public void viewDown() {
- offset.y -= 15 * magnification * PropsUi.getNativeZoomFactor();
+ arrowKey(0, 1, false);
+ }
+
+ @GuiKeyboardShortcut(shift = true, key = SWT.ARROW_LEFT)
+ @GuiOsxKeyboardShortcut(shift = true, key = SWT.ARROW_LEFT)
+ public void viewLeftFast() {
+ arrowKey(-1, 0, true);
+ }
+
+ @GuiKeyboardShortcut(shift = true, key = SWT.ARROW_RIGHT)
+ @GuiOsxKeyboardShortcut(shift = true, key = SWT.ARROW_RIGHT)
+ public void viewRightFast() {
+ arrowKey(1, 0, true);
+ }
+
+ @GuiKeyboardShortcut(shift = true, key = SWT.ARROW_UP)
+ @GuiOsxKeyboardShortcut(shift = true, key = SWT.ARROW_UP)
+ public void viewUpFast() {
+ arrowKey(0, -1, true);
+ }
+
+ @GuiKeyboardShortcut(shift = true, key = SWT.ARROW_DOWN)
+ @GuiOsxKeyboardShortcut(shift = true, key = SWT.ARROW_DOWN)
+ public void viewDownFast() {
+ arrowKey(0, 1, true);
+ }
+
+ /**
+ * Handle an arrow key: move the selected elements when there are any, pan
the view otherwise.
+ *
+ * @param dx -1, 0 or 1: the horizontal direction
+ * @param dy -1, 0 or 1: the vertical direction
+ * @param largeStep true when SHIFT is held down: take a larger step
+ */
+ protected void arrowKey(int dx, int dy, boolean largeStep) {
+ if (moveSelectionWithArrowKey(dx, dy, largeStep)) {
+ return;
+ }
+ double step = calculatePanStep(largeStep);
+ offset.x -= dx * step;
+ offset.y -= dy * step;
validateOffset();
redraw();
}
+ /**
+ * Move the elements selected on the canvas with the arrow keys. Only the
editors (pipeline and
+ * workflow graph) support this, the execution viewers always pan the view.
+ *
+ * @param dx -1, 0 or 1: the horizontal direction
+ * @param dy -1, 0 or 1: the vertical direction
+ * @param largeStep true when SHIFT is held down: take a larger step
+ * @return true if the arrow key moved a selection, false to pan the view
instead
+ */
+ protected boolean moveSelectionWithArrowKey(int dx, int dy, boolean
largeStep) {
+ return false;
+ }
+
+ /**
+ * The distance to pan for a single arrow key press, in graph coordinates.
It compensates for the
+ * magnification so that the view always moves the same visible distance.
+ *
+ * @param largeStep true to take a larger step (SHIFT)
+ * @return the distance to pan in graph coordinates
+ */
+ protected double calculatePanStep(boolean largeStep) {
+ double step = PAN_STEP / Math.max(0.1f, magnification);
+ return largeStep ? step * LARGE_STEP_FACTOR : step;
+ }
+
@GuiKeyboardShortcut(control = true, key = '+')
@GuiOsxKeyboardShortcut(command = true, key = '+')
public void zoomIn() {
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
new file mode 100644
index 0000000000..14e34093e3
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java
@@ -0,0 +1,268 @@
+/*
+ * 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;
+
+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.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.key.KeyboardShortcut;
+import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.KeyEvent;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.Tree;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests keyboard shortcut dispatching, in particular for shortcuts that a
{@code @GuiPlugin} class
+ * inherits from a super class. The canvas navigation keys (arrows, HOME,
zoom) of the pipeline and
+ * workflow graphs are declared on {@code DragViewZoomBase} and inherited, so
they only work when
+ * inherited shortcuts are dispatched.
+ */
+class HopGuiKeyHandlerTest {
+
+ /** Stands in for DragViewZoomBase: declares the canvas navigation shortcut.
*/
+ public static class NavigationBase {
+ public int left;
+ public int leftFast;
+
+ @GuiKeyboardShortcut(key = SWT.ARROW_LEFT)
+ @GuiOsxKeyboardShortcut(key = SWT.ARROW_LEFT)
+ public void viewLeft() {
+ left++;
+ }
+
+ @GuiKeyboardShortcut(shift = true, key = SWT.ARROW_LEFT)
+ @GuiOsxKeyboardShortcut(shift = true, key = SWT.ARROW_LEFT)
+ public void viewLeftFast() {
+ leftFast++;
+ }
+ }
+
+ /** Stands in for HopGuiPipelineGraph: the @GuiPlugin class that inherits
the shortcuts. */
+ public static class NavigationGraph extends NavigationBase {}
+
+ @Test
+ void shortcutInheritedFromSuperClassIsDispatched() {
+ NavigationGraph graph = new NavigationGraph();
+ registerShortcutsLikeHopGuiEnvironment(NavigationGraph.class);
+
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ keyHandler.addParentObjectToHandle(graph);
+ try {
+ keyHandler.keyPressed(canvasKey(SWT.ARROW_LEFT, SWT.NONE));
+ assertEquals(1, graph.left, "Bare arrow key must reach the inherited
canvas pan method");
+
+ keyHandler.keyPressed(canvasKey(SWT.ARROW_LEFT, SWT.SHIFT));
+ assertEquals(1, graph.leftFast, "Shift+arrow must reach the inherited
large step method");
+ assertEquals(1, graph.left, "Shift+arrow must not also trigger the small
step");
+ } finally {
+ keyHandler.removeParentObjectToHandle(graph);
+ }
+ }
+
+ @Test
+ void arrowKeysAreLeftToTextWidgets() {
+ NavigationGraph graph = new NavigationGraph();
+ registerShortcutsLikeHopGuiEnvironment(NavigationGraph.class);
+
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ keyHandler.addParentObjectToHandle(graph);
+ try {
+ KeyEvent event = keyEvent(mock(Text.class), SWT.ARROW_LEFT, SWT.NONE);
+ keyHandler.keyPressed(event);
+ assertEquals(0, graph.left, "Arrow keys must stay in text widgets for
caret movement");
+ } finally {
+ keyHandler.removeParentObjectToHandle(graph);
+ }
+ }
+
+ @Test
+ void arrowKeysAreLeftToTablesAndTrees() {
+ NavigationGraph graph = new NavigationGraph();
+ registerShortcutsLikeHopGuiEnvironment(NavigationGraph.class);
+
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ keyHandler.addParentObjectToHandle(graph);
+ try {
+ // Tables and trees live inside the graph as well: log, preview and
result tabs
+ keyHandler.keyPressed(keyEvent(mock(Table.class), SWT.ARROW_LEFT,
SWT.NONE));
+ keyHandler.keyPressed(keyEvent(mock(Tree.class), SWT.ARROW_LEFT,
SWT.NONE));
+ keyHandler.keyPressed(keyEvent(mock(Table.class), SWT.ARROW_LEFT,
SWT.SHIFT));
+ assertEquals(0, graph.left, "Arrow keys must stay in tables and trees to
move through rows");
+ assertEquals(0, graph.leftFast, "Shift+arrow must stay in tables to
extend the selection");
+ } finally {
+ keyHandler.removeParentObjectToHandle(graph);
+ }
+ }
+
+ @Test
+ void attachesToWidgetsCreatedAfterTheShellWasHandled() {
+ // A metadata editor that rebuilds a section (e.g. the OAuth 2 fields of a
REST connection)
+ // creates widgets that never got the key listener, so Ctrl+S did nothing
while they had focus.
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ Shell shell = mock(Shell.class);
+ Text lateCreatedWidget = mock(Text.class);
+ when(lateCreatedWidget.getShell()).thenReturn(shell);
+
+ assertFalse(
+ keyHandler.attachTo(lateCreatedWidget),
+ "Widgets of a shell we don't handle must be left alone");
+
+ keyHandler.addHandledShell(null, shell);
+
+ assertTrue(keyHandler.attachTo(lateCreatedWidget));
+ verify(lateCreatedWidget).addKeyListener(keyHandler);
+ }
+
+ @Test
+ void doesNotAttachToWidgetsThatPassKeyListenersOn() {
+ // TextVar styles itself before creating the Text inside it and passes key
listeners on to that
+ // Text, so attaching while it is being built threw a
NullPointerException. The Text inside it
+ // is styled separately, which is where it gets the handler.
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ Shell shell = mock(Shell.class);
+ TextVar textVar = mock(TextVar.class);
+ when(textVar.getShell()).thenReturn(shell);
+ keyHandler.addHandledShell(null, shell);
+
+ assertFalse(keyHandler.attachTo(textVar));
+ verify(textVar, never()).addKeyListener(keyHandler);
+ }
+
+ @Test
+ void doesNotAttachToTheTerminalWidget() {
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ Shell shell = mock(Shell.class);
+ Text terminalWidget = mock(Text.class);
+ when(terminalWidget.getShell()).thenReturn(shell);
+
when(terminalWidget.getData(HopGuiKeyHandler.HOP_TERMINAL_WIDGET)).thenReturn(Boolean.TRUE);
+ keyHandler.addHandledShell(null, shell);
+
+ assertFalse(keyHandler.attachTo(terminalWidget));
+ verify(terminalWidget, never()).addKeyListener(keyHandler);
+ }
+
+ @Test
+ void pipelineGraphBindsArrowKeys() {
+ assertGraphBindsArrowKeys(HopGuiPipelineGraph.class);
+ }
+
+ @Test
+ void workflowGraphBindsArrowKeys() {
+ assertGraphBindsArrowKeys(HopGuiWorkflowGraph.class);
+ }
+
+ private void assertGraphBindsArrowKeys(Class<?> graphClass) {
+ registerShortcutsLikeHopGuiEnvironment(graphClass);
+ List<KeyboardShortcut> shortcuts =
+ GuiRegistry.getInstance().getKeyboardShortcuts(graphClass.getName());
+ assertNotNull(shortcuts, "No keyboard shortcuts registered for " +
graphClass.getName());
+
+ for (int keyCode : new int[] {SWT.ARROW_LEFT, SWT.ARROW_RIGHT,
SWT.ARROW_UP, SWT.ARROW_DOWN}) {
+ for (boolean shift : new boolean[] {false, true}) {
+ KeyboardShortcut shortcut = findArrowShortcut(shortcuts, keyCode,
shift);
+ assertNotNull(
+ shortcut, "No " + (shift ? "shift+" : "") + "arrow key shortcut on
" + graphClass);
+ // This is what HopGuiKeyHandler checks before invoking the method
+ assertEquals(
+ graphClass.getName(),
+ shortcut.getParentClassName(),
+ "Shortcut must be linked to the graph class, not to the class
declaring the method");
+ try {
+ assertNotNull(graphClass.getMethod(shortcut.getParentMethodName()));
+ } catch (NoSuchMethodException e) {
+ throw new AssertionError("Method not callable on " + graphClass, e);
+ }
+ }
+ }
+ }
+
+ private KeyboardShortcut findArrowShortcut(
+ List<KeyboardShortcut> shortcuts, int keyCode, boolean shift) {
+ for (KeyboardShortcut shortcut : shortcuts) {
+ if (shortcut.getKeyCode() == keyCode
+ && shortcut.isShift() == shift
+ && !shortcut.isAlt()
+ && !shortcut.isControl()
+ && !shortcut.isCommand()) {
+ return shortcut;
+ }
+ }
+ return null;
+ }
+
+ /** Register the shortcuts of a class the same way HopGuiEnvironment does at
startup. */
+ private void registerShortcutsLikeHopGuiEnvironment(Class<?> guiPluginClass)
{
+ GuiRegistry registry = GuiRegistry.getInstance();
+ if (registry.getKeyboardShortcuts(guiPluginClass.getName()) != null) {
+ return; // The registry is a singleton: only register once per test run
+ }
+ for (Method method : findDeclaredMethods(guiPluginClass)) {
+ GuiKeyboardShortcut shortcut =
method.getAnnotation(GuiKeyboardShortcut.class);
+ if (shortcut != null) {
+ registry.addKeyboardShortcut(guiPluginClass.getName(), method,
shortcut);
+ }
+ GuiOsxKeyboardShortcut osxShortcut =
method.getAnnotation(GuiOsxKeyboardShortcut.class);
+ if (osxShortcut != null) {
+ registry.addKeyboardShortcut(guiPluginClass.getName(), method,
osxShortcut);
+ }
+ }
+ }
+
+ private List<Method> findDeclaredMethods(Class<?> parentClass) {
+ List<Method> methods = new ArrayList<>();
+ Class<?> current = parentClass;
+ while (current != null) {
+ methods.addAll(List.of(current.getDeclaredMethods()));
+ current = current.getSuperclass();
+ }
+ return methods;
+ }
+
+ private KeyEvent canvasKey(int keyCode, int stateMask) {
+ return keyEvent(mock(Canvas.class), keyCode, stateMask);
+ }
+
+ private KeyEvent keyEvent(org.eclipse.swt.widgets.Widget widget, int
keyCode, int stateMask) {
+ Event event = new Event();
+ event.widget = widget;
+ event.keyCode = keyCode;
+ event.stateMask = stateMask;
+ event.doit = true;
+ return new KeyEvent(event);
+ }
+}