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 0c141faedd Issue #8362 : Keep word movement and line copy in text
fields (#8576)
0c141faedd is described below
commit 0c141faedd8e88348b4fcb02dfdf4b6d4847d597
Author: Matt Casters <[email protected]>
AuthorDate: Thu Sep 24 15:31:00 2026 +0200
Issue #8362 : Keep word movement and line copy in text fields (#8576)
---
.../modules/ROOT/pages/hop-gui/shortcuts.adoc | 3 +
.../main/java/org/apache/hop/ui/hopgui/HopWeb.java | 3 +-
.../org/apache/hop/ui/hopgui/HopWebEntryPoint.java | 21 +-
.../org/apache/hop/ui/hopgui/monaco-editor.js | 2 +
.../apache/hop/ui/hopgui/text-line-clipboard.js | 132 ++++++++
.../apache/hop/ui/hopgui/HopWebEntryPointTest.java | 26 +-
.../apache/hop/ui/core/widget/StyledTextComp.java | 8 +-
.../ui/core/widget/StyledTextLineClipboard.java | 90 ++++++
.../apache/hop/ui/core/widget/StyledTextVar.java | 8 +-
.../apache/hop/ui/core/widget/TextComposite.java | 16 +-
.../hop/ui/core/widget/TextLineClipboard.java | 344 +++++++++++++++++++++
.../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java | 194 ++++++++++--
.../hop/ui/core/widget/TextLineClipboardTest.java | 178 +++++++++++
.../apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java | 104 +++++++
14 files changed, 1080 insertions(+), 49 deletions(-)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/shortcuts.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/shortcuts.adoc
index 934cd0fde1..bf26bb7684 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/shortcuts.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/shortcuts.adoc
@@ -27,6 +27,9 @@ Widget-local shortcuts (they apply to the focused field, not
the whole window) a
[options="header"]
|===
|Shortcut|Where|What it does
+|`CTRL-LEFT` / `CTRL-RIGHT`|Any text field. On macOS, `OPTION-LEFT` /
`OPTION-RIGHT`|Move to the previous or next word. Hold Shift to extend the
selection. `CMD-LEFT` / `CMD-RIGHT` on macOS still go to the start or end of
the line
+|`CTRL-C` / `CMD-C`|Any text field|Copy the selection, or the current line
when nothing is selected
+|`CTRL-X` / `CMD-X`|Any text field|Cut the selection, or the current line when
nothing is selected. Read-only text is not cut
|`CTRL-SPACE`|`TextVar` / `ComboVar` that accept variables|Insert a
`+${variable}+`
|`CTRL-SPACE`|Name fields that do *not* accept variables
(transform/action/metadata names)|Apply a
xref:metadata-types/naming-scheme.adoc[Naming Scheme] of the matching type
|`CTRL-SHIFT-N`|Any name field, field-name cell, table name, file/folder
field, or F2 tree rename|List and apply a Naming Scheme. Replaces the current
identifier (values that contain `${...}` are left unchanged)
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index e3cd2da161..794da03453 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -173,7 +173,8 @@ public class HopWeb implements ApplicationConfiguration {
"org/apache/hop/ui/hopgui/gc-pending-images.js",
"org/apache/hop/ui/hopgui/dnd-cursor-passthrough.js",
"org/apache/hop/ui/hopgui/monaco-editor.js",
- "org/apache/hop/ui/hopgui/mac-command-keys.js")
+ "org/apache/hop/ui/hopgui/mac-command-keys.js",
+ "org/apache/hop/ui/hopgui/text-line-clipboard.js")
.forEach(
str ->
application.addResource(
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 bbac59e6ae..ba98a7dfd8 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
@@ -72,9 +72,12 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
* 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.
+ * be in {@code CANCEL_KEYS} or the browser never moves the caret (see issue
#7833).
+ *
+ * <p>Horizontal Ctrl/Alt+Left/Right are the same kind of key (issue #8362):
the browser moves by
+ * word (Ctrl on Windows and Linux, Alt/Option on macOS) or extends that
selection with Shift.
+ * They stay active so the canvas can still align or distribute when focus
is not a text field.
+ * Vertical modifier arrows (align top/bottom, distribute vertically) stay
cancelled.
*/
private static final Set<String> NATIVE_TEXT_NAVIGATION_KEYS =
Set.of(
@@ -93,7 +96,15 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
"SHIFT+HOME",
"SHIFT+END",
"SHIFT+PAGE_UP",
- "SHIFT+PAGE_DOWN");
+ "SHIFT+PAGE_DOWN",
+ "CTRL+ARROW_LEFT",
+ "CTRL+ARROW_RIGHT",
+ "CTRL+SHIFT+ARROW_LEFT",
+ "CTRL+SHIFT+ARROW_RIGHT",
+ "ALT+ARROW_LEFT",
+ "ALT+ARROW_RIGHT",
+ "ALT+SHIFT+ARROW_LEFT",
+ "ALT+SHIFT+ARROW_RIGHT");
/** Audit group/type/name for Hop Web theme preference (per-user in audit
folder). */
public static final String AUDIT_GROUP_HOP_WEB = "hop-web";
@@ -206,6 +217,8 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
// Map Mac Command key to Ctrl so RAP ACTIVE_KEYS (CTRL+S etc.) match when
user presses Cmd+S
String macKeysLocation =
resourceManager.getLocation("js/mac-command-keys.js");
jsLoader.require(macKeysLocation);
+ // Empty Ctrl/Cmd+C/X copies or cuts the current line. Must run in the key
gesture.
+ jsLoader.require(resourceManager.getLocation("js/text-line-clipboard.js"));
// Configure keyboard shortcuts for RAP dynamically from annotations
// ACTIVE_KEYS tells RAP to send these key combinations to the server
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
index 83cda4c122..64a9743488 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
@@ -332,6 +332,8 @@
language: langForEditor,
theme: self._theme,
readOnly: self._readOnly,
+ // Copy or cut the current line when nothing is selected, same as
the other text fields.
+ emptySelectionClipboard: true,
automaticLayout: true,
scrollBeyondLastLine: false,
minimap: { enabled: true },
diff --git
a/rap/src/main/resources/org/apache/hop/ui/hopgui/text-line-clipboard.js
b/rap/src/main/resources/org/apache/hop/ui/hopgui/text-line-clipboard.js
new file mode 100644
index 0000000000..ff2f792ef8
--- /dev/null
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/text-line-clipboard.js
@@ -0,0 +1,132 @@
+/*
+ * 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.
+ */
+
+/**
+ * Copy or cut the current line when a text field has no selection.
+ *
+ * Runs in the capture phase, during the key gesture, because a clipboard
write started from the
+ * server round trip is rejected by the browser. Monaco does this itself
(emptySelectionClipboard).
+ * Word movement is left to the browser: those chords are not in RAP
CANCEL_KEYS.
+ */
+(function () {
+ 'use strict';
+
+ function isTextField(el) {
+ if (!el || !el.tagName) {
+ return false;
+ }
+ var tag = el.tagName.toLowerCase();
+ if (tag === 'textarea') {
+ return true;
+ }
+ if (tag !== 'input') {
+ return false;
+ }
+ var type = (el.type || 'text').toLowerCase();
+ if (type === 'password' || type === 'hidden' || type === 'checkbox' ||
type === 'radio'
+ || type === 'button' || type === 'submit' || type === 'file' || type
=== 'number') {
+ return false;
+ }
+ return typeof el.selectionStart === 'number' && typeof el.selectionEnd ===
'number';
+ }
+
+ function inMonaco(el) {
+ return el.closest && el.closest('.monaco-editor');
+ }
+
+ /** Half-open [start, end) of the line containing caret. Matches
TextLineClipboard.lineRange. */
+ function lineRange(text, caret) {
+ var length = text.length;
+ if (caret < 0) {
+ caret = 0;
+ } else if (caret > length) {
+ caret = length;
+ }
+ if (caret > 0 && caret < length && text.charAt(caret - 1) === '\r' &&
text.charAt(caret) === '\n') {
+ caret--;
+ }
+ var start = caret;
+ while (start > 0) {
+ var previous = text.charAt(start - 1);
+ if (previous === '\n' || previous === '\r') {
+ break;
+ }
+ start--;
+ }
+ var end = caret;
+ while (end < length) {
+ var current = text.charAt(end);
+ if (current === '\r') {
+ end++;
+ if (end < length && text.charAt(end) === '\n') {
+ end++;
+ }
+ break;
+ }
+ if (current === '\n') {
+ end++;
+ break;
+ }
+ end++;
+ }
+ return { start: start, end: end };
+ }
+
+ document.addEventListener('keydown', function (event) {
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) {
+ return;
+ }
+ var key = (event.key || '').toLowerCase();
+ if (key !== 'c' && key !== 'x') {
+ return;
+ }
+ var el = event.target;
+ if (!isTextField(el) || inMonaco(el)) {
+ return;
+ }
+ if (el.selectionStart !== el.selectionEnd) {
+ return;
+ }
+ if (key === 'x' && (el.readOnly || el.disabled)) {
+ return;
+ }
+
+ var caret = el.selectionStart;
+ var before = el.value || '';
+ var range = lineRange(before, caret);
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ var command = key === 'x' ? 'cut' : 'copy';
+ el.setSelectionRange(range.start, range.end);
+ var copied = false;
+ try {
+ copied = document.execCommand(command);
+ } catch (e) {
+ copied = false;
+ }
+ if (command === 'copy' || !copied) {
+ el.setSelectionRange(caret, caret);
+ return;
+ }
+ // RAP syncs the text widget from the input event. execCommand usually
fires one; fire one
+ // ourselves as well so a browser that does not still updates the server.
+ if (el.value !== before) {
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }, true);
+})();
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 41ad7cc834..c288084e77 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
@@ -54,12 +54,36 @@ class HopWebEntryPointTest {
"PAGE_UP",
"PAGE_DOWN",
"CTRL+ARROW_LEFT",
+ "CTRL+ARROW_UP",
"CTRL+S"
};
String[] cancelledShortcuts =
HopWebEntryPoint.buildCancelledKeyboardShortcuts(activeShortcuts);
- assertArrayEquals(new String[] {"CTRL+ARROW_LEFT", "CTRL+S"},
cancelledShortcuts);
+ assertArrayEquals(new String[] {"CTRL+ARROW_UP", "CTRL+S"},
cancelledShortcuts);
+ }
+
+ @Test
+ void doesNotCancelWordNavigationKeys() {
+ // These stay active for the canvas, but the browser must keep them so a
text field can move
+ // or select by word. Vertical modifier arrows are still cancelled.
+ String[] activeShortcuts = {
+ "CTRL+ARROW_LEFT",
+ "CTRL+ARROW_RIGHT",
+ "CTRL+SHIFT+ARROW_LEFT",
+ "CTRL+SHIFT+ARROW_RIGHT",
+ "ALT+ARROW_LEFT",
+ "ALT+ARROW_RIGHT",
+ "ALT+SHIFT+ARROW_LEFT",
+ "ALT+SHIFT+ARROW_RIGHT",
+ "CTRL+ARROW_UP",
+ "ALT+ARROW_UP",
+ "CTRL+S"
+ };
+
+ String[] cancelledShortcuts =
HopWebEntryPoint.buildCancelledKeyboardShortcuts(activeShortcuts);
+
+ assertArrayEquals(new String[] {"CTRL+ARROW_UP", "ALT+ARROW_UP",
"CTRL+S"}, cancelledShortcuts);
}
@Test
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
index cb1d9096da..5f58fc6268 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextComp.java
@@ -305,12 +305,16 @@ public class StyledTextComp extends TextComposite {
@Override
public void cut() {
- textWidget.cut();
+ if (!TextLineClipboard.copyOrCutCurrentLine(textWidget, true)) {
+ textWidget.cut();
+ }
}
@Override
public void copy() {
- textWidget.copy();
+ if (!TextLineClipboard.copyOrCutCurrentLine(textWidget, false)) {
+ textWidget.copy();
+ }
}
@Override
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextLineClipboard.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextLineClipboard.java
new file mode 100644
index 0000000000..799322079e
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextLineClipboard.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.widgets.Widget;
+
+/**
+ * StyledText half of {@link TextLineClipboard}. Kept in its own class so Hop
Web, which has no
+ * StyledText, does not load this class.
+ */
+final class StyledTextLineClipboard {
+
+ private StyledTextLineClipboard() {}
+
+ static TextLineClipboard.Editor editorFor(Widget widget) {
+ if (!(widget instanceof StyledText styledText)) {
+ return null;
+ }
+ return new Editor(styledText);
+ }
+
+ private static final class Editor implements TextLineClipboard.Editor {
+ private final StyledText text;
+
+ private Editor(StyledText text) {
+ this.text = text;
+ }
+
+ @Override
+ public boolean isPassword() {
+ return false;
+ }
+
+ @Override
+ public boolean isEditable() {
+ return text.getEditable();
+ }
+
+ @Override
+ public boolean hasSelection() {
+ return text.getSelectionCount() > 0;
+ }
+
+ @Override
+ public String getText() {
+ return text.getText();
+ }
+
+ @Override
+ public int getCaret() {
+ return text.getCaretOffset();
+ }
+
+ @Override
+ public void select(int start, int end) {
+ text.setSelection(start, end);
+ }
+
+ @Override
+ public void copy() {
+ text.copy();
+ }
+
+ @Override
+ public void cut() {
+ text.cut();
+ }
+
+ @Override
+ public void setCaret(int offset) {
+ text.setSelection(offset);
+ }
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
index 230c92cf87..f871f39f1b 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
@@ -352,12 +352,16 @@ public class StyledTextVar extends TextComposite {
@Override
public void cut() {
- wText.cut();
+ if (!TextLineClipboard.copyOrCutCurrentLine(wText, true)) {
+ wText.cut();
+ }
}
@Override
public void copy() {
- wText.copy();
+ if (!TextLineClipboard.copyOrCutCurrentLine(wText, false)) {
+ wText.copy();
+ }
}
@Override
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
index 32669fdd76..5e95f883b4 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextComposite.java
@@ -277,15 +277,15 @@ public abstract class TextComposite extends Composite
implements IFindReplaceTar
return;
}
boolean editable = isEditable();
- boolean hasSelection = getSelectionCount() > 0;
+ // Copy and cut stay available with an empty selection: they then use the
current line.
// This runs on every Modify/Selection event: never consult the clipboard
here. On desktop
// that is a system IPC per keystroke, on Hop Web a blocking browser round
trip (see #8498).
boolean canPaste = editable;
toolbarWidgets.enableToolbarItem(ID_TOOLBAR_UNDO, canUndo());
toolbarWidgets.enableToolbarItem(ID_TOOLBAR_REDO, canRedo());
- toolbarWidgets.enableToolbarItem(ID_TOOLBAR_CUT, editable && hasSelection);
- toolbarWidgets.enableToolbarItem(ID_TOOLBAR_COPY, hasSelection);
+ toolbarWidgets.enableToolbarItem(ID_TOOLBAR_CUT, editable);
+ toolbarWidgets.enableToolbarItem(ID_TOOLBAR_COPY, true);
toolbarWidgets.enableToolbarItem(ID_TOOLBAR_PASTE, canPaste);
toolbarWidgets.enableToolbarItem(ID_TOOLBAR_SELECT_ALL, getCharCount() >
0);
toolbarWidgets.enableToolbarItem(ID_TOOLBAR_FIND, true);
@@ -747,13 +747,9 @@ public abstract class TextComposite extends Composite
implements IFindReplaceTar
addMenuDetectListener(
event -> {
pasteItem.setEnabled(checkPaste());
- if (getSelectionCount() > 0) {
- cutItem.setEnabled(true);
- copyItem.setEnabled(true);
- } else {
- cutItem.setEnabled(false);
- copyItem.setEnabled(false);
- }
+ // An empty selection copies or cuts the current line, so copy stays
enabled.
+ copyItem.setEnabled(true);
+ cutItem.setEnabled(isEditable());
findReplaceItem.setEnabled(isEditable());
updateToolbar();
});
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/TextLineClipboard.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TextLineClipboard.java
new file mode 100644
index 0000000000..d27341447b
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextLineClipboard.java
@@ -0,0 +1,344 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.custom.CCombo;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.Widget;
+
+/**
+ * Copies or cuts the line under the caret when a text widget has no selection.
+ *
+ * <p>A selection is left to the widget. Password fields are left alone. The
line includes its line
+ * break ({@code \n}, {@code \r\n} or {@code \r}) when it has one; the last
line does not gain a
+ * break it did not have.
+ */
+public final class TextLineClipboard {
+
+ private TextLineClipboard() {}
+
+ /**
+ * Line that contains {@code caret}, as a half-open range {@code [start,
end)}.
+ *
+ * <p>A caret sitting between the CR and LF of a CRLF still belongs to the
line that ends with
+ * that break.
+ */
+ public static int[] lineRange(String text, int caret) {
+ if (text == null) {
+ text = "";
+ }
+ int length = text.length();
+ if (caret < 0) {
+ caret = 0;
+ } else if (caret > length) {
+ caret = length;
+ }
+ if (caret > 0
+ && caret < length
+ && text.charAt(caret - 1) == '\r'
+ && text.charAt(caret) == '\n') {
+ caret--;
+ }
+
+ int start = caret;
+ while (start > 0) {
+ char previous = text.charAt(start - 1);
+ if (previous == '\n' || previous == '\r') {
+ break;
+ }
+ start--;
+ }
+
+ int end = caret;
+ while (end < length) {
+ char current = text.charAt(end);
+ if (current == '\r') {
+ end++;
+ if (end < length && text.charAt(end) == '\n') {
+ end++;
+ }
+ break;
+ }
+ if (current == '\n') {
+ end++;
+ break;
+ }
+ end++;
+ }
+ return new int[] {start, end};
+ }
+
+ /**
+ * Copies or cuts the current line when {@code widget} has no selection.
+ *
+ * @param cut {@code true} to cut, {@code false} to copy
+ * @return {@code true} when the line was copied or cut. {@code false} when
the widget should keep
+ * its own copy/cut (a selection, a password field, a read-only cut, or
an unsupported
+ * widget).
+ */
+ public static boolean copyOrCutCurrentLine(Widget widget, boolean cut) {
+ try {
+ Editor editor = editorFor(widget);
+ if (editor == null) {
+ return false;
+ }
+ return copyOrCutCurrentLine(editor, cut);
+ } catch (SWTException e) {
+ return false;
+ }
+ }
+
+ static boolean copyOrCutCurrentLine(Editor editor, boolean cut) {
+ if (editor == null || editor.isPassword() || editor.hasSelection()) {
+ return false;
+ }
+ if (cut && !editor.isEditable()) {
+ return false;
+ }
+ String text = editor.getText();
+ if (text == null) {
+ text = "";
+ }
+ int caret = editor.getCaret();
+ int[] range = lineRange(text, caret);
+ editor.select(range[0], range[1]);
+ if (cut) {
+ editor.cut();
+ editor.setCaret(range[0]);
+ } else {
+ try {
+ editor.copy();
+ } finally {
+ editor.setCaret(caret);
+ }
+ }
+ return true;
+ }
+
+ private static Editor editorFor(Widget widget) {
+ if (widget instanceof Text text) {
+ return new TextEditor(text);
+ }
+ if (widget instanceof Combo combo) {
+ return new ComboEditor(combo);
+ }
+ if (widget instanceof CCombo combo) {
+ return new CComboEditor(combo);
+ }
+ if (isStyledText(widget)) {
+ return StyledTextLineClipboard.editorFor(widget);
+ }
+ return null;
+ }
+
+ private static boolean isStyledText(Widget widget) {
+ try {
+ Class<?> type = Class.forName("org.eclipse.swt.custom.StyledText");
+ return type.isInstance(widget);
+ } catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+
+ /** A text caret that can copy or cut its own selection. Package-visible for
tests. */
+ interface Editor {
+ boolean isPassword();
+
+ boolean isEditable();
+
+ boolean hasSelection();
+
+ String getText();
+
+ int getCaret();
+
+ void select(int start, int end);
+
+ void copy();
+
+ void cut();
+
+ void setCaret(int offset);
+ }
+
+ private static final class TextEditor implements Editor {
+ private final Text text;
+
+ private TextEditor(Text text) {
+ this.text = text;
+ }
+
+ @Override
+ public boolean isPassword() {
+ return (text.getStyle() & SWT.PASSWORD) != 0;
+ }
+
+ @Override
+ public boolean isEditable() {
+ return text.getEditable();
+ }
+
+ @Override
+ public boolean hasSelection() {
+ return text.getSelectionCount() > 0;
+ }
+
+ @Override
+ public String getText() {
+ return text.getText();
+ }
+
+ @Override
+ public int getCaret() {
+ return text.getCaretPosition();
+ }
+
+ @Override
+ public void select(int start, int end) {
+ text.setSelection(start, end);
+ }
+
+ @Override
+ public void copy() {
+ text.copy();
+ }
+
+ @Override
+ public void cut() {
+ text.cut();
+ }
+
+ @Override
+ public void setCaret(int offset) {
+ text.setSelection(offset);
+ }
+ }
+
+ private static final class ComboEditor implements Editor {
+ private final Combo combo;
+
+ private ComboEditor(Combo combo) {
+ this.combo = combo;
+ }
+
+ @Override
+ public boolean isPassword() {
+ return (combo.getStyle() & SWT.PASSWORD) != 0;
+ }
+
+ @Override
+ public boolean isEditable() {
+ return (combo.getStyle() & SWT.READ_ONLY) == 0;
+ }
+
+ @Override
+ public boolean hasSelection() {
+ Point selection = combo.getSelection();
+ return selection != null && selection.x != selection.y;
+ }
+
+ @Override
+ public String getText() {
+ return combo.getText();
+ }
+
+ @Override
+ public int getCaret() {
+ return combo.getCaretPosition();
+ }
+
+ @Override
+ public void select(int start, int end) {
+ combo.setSelection(new Point(start, end));
+ }
+
+ @Override
+ public void copy() {
+ combo.copy();
+ }
+
+ @Override
+ public void cut() {
+ combo.cut();
+ }
+
+ @Override
+ public void setCaret(int offset) {
+ combo.setSelection(new Point(offset, offset));
+ }
+ }
+
+ private static final class CComboEditor implements Editor {
+ private final CCombo combo;
+
+ private CComboEditor(CCombo combo) {
+ this.combo = combo;
+ }
+
+ @Override
+ public boolean isPassword() {
+ return false;
+ }
+
+ @Override
+ public boolean isEditable() {
+ return combo.getEditable();
+ }
+
+ @Override
+ public boolean hasSelection() {
+ Point selection = combo.getSelection();
+ return selection != null && selection.x != selection.y;
+ }
+
+ @Override
+ public String getText() {
+ return combo.getText();
+ }
+
+ @Override
+ public int getCaret() {
+ Point selection = combo.getSelection();
+ return selection == null ? 0 : selection.x;
+ }
+
+ @Override
+ public void select(int start, int end) {
+ combo.setSelection(new Point(start, end));
+ }
+
+ @Override
+ public void copy() {
+ combo.copy();
+ }
+
+ @Override
+ public void cut() {
+ combo.cut();
+ }
+
+ @Override
+ public void setCaret(int offset) {
+ combo.setSelection(new Point(offset, offset));
+ }
+ }
+}
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 1aeaf512c0..172eeca817 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
@@ -31,7 +31,9 @@ import org.apache.hop.core.gui.plugin.GuiRegistry;
import org.apache.hop.core.gui.plugin.key.KeyboardShortcut;
import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.security.ActionPermissionMapper;
+import org.apache.hop.ui.core.widget.TextLineClipboard;
import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
+import org.apache.hop.ui.util.EnvironmentUtils;
import org.eclipse.swt.SWT;
import org.eclipse.swt.SWTException;
import org.eclipse.swt.custom.CCombo;
@@ -42,6 +44,7 @@ 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.Event;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Table;
@@ -144,12 +147,30 @@ public class HopGuiKeyHandler extends KeyAdapter {
// 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.
//
+ // The key filter covers every shell on this display, including dialogs
that never register
+ // here, so word-movement keys are not stolen and an empty Ctrl/Cmd+C/X
copies or cuts the
+ // current line (issue #8362).
+ //
if (display != null && !display.isDisposed() &&
filteredDisplays.add(display)) {
display.addFilter(SWT.FocusIn, event -> attachTo(event.widget));
+ display.addFilter(SWT.KeyDown, this::filterTextEditingKey);
display.addListener(SWT.Dispose, e -> filteredDisplays.remove(display));
}
}
+ /** Display filter: runs before widget listeners, for shells that never got
this handler. */
+ private void filterTextEditingKey(Event event) {
+ try {
+ if (applyTextEditingKey(
+ event.widget, event.keyCode, event.stateMask, event.character,
event.display)
+ .consume) {
+ event.doit = false;
+ }
+ } catch (SWTException e) {
+ // The widget was disposed while the key was delivered.
+ }
+ }
+
/**
* Attach this handler to a widget of a shell we handle, so its keyboard
shortcuts work.
*
@@ -230,19 +251,25 @@ public class HopGuiKeyHandler extends KeyAdapter {
return;
}
- // Do not steal keys needed for native editing / caret movement inside
text-like widgets.
- // StyledText is not available in RAP, so we check via reflection to avoid
NoClassDefFoundError.
- // Bare ARROW_*/HOME/END would otherwise match canvas pan shortcuts
(DragViewZoomBase) and break
- // caret navigation — especially in Hop Web where RAP CANCEL_KEYS can also
block the browser
- // (see issue #7833). App shortcuts with CTRL/CMD/ALT (e.g. Ctrl+S,
Ctrl+Arrow) still run.
- if (isTextLikeWidget(event.widget) && isNativeTextEditingKey(event)) {
+ try {
+ TextEditing textEditing =
+ applyTextEditingKey(
+ event.widget, event.keyCode, event.stateMask, event.character,
event.display);
+ if (textEditing.consume) {
+ event.doit = false;
+ }
+ if (textEditing.stopShortcuts) {
+ return;
+ }
+ } catch (SWTException e) {
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)) {
+ if (isRowNavigationWidget(event.widget)
+ && isCaretNavigationKey(event.keyCode, event.stateMask)) {
return;
}
@@ -478,6 +505,114 @@ public class HopGuiKeyHandler extends KeyAdapter {
|| widget instanceof org.eclipse.swt.widgets.List;
}
+ /**
+ * Keeps text editing in the widget that has the caret.
+ *
+ * <p>Horizontal Ctrl/Cmd/Alt+Left/Right stay with the widget (word movement
on Windows and Linux,
+ * Option+Left/Right on macOS, line edges for Command+Left/Right). They must
not align or
+ * distribute the graph. An empty Ctrl/Cmd+C or Ctrl/Cmd+X copies or cuts
the current line on the
+ * desktop; Hop Web does that in the browser, inside the key gesture. Other
text keys (bare
+ * arrows, Ctrl+A/V, typing) are not dispatched as shortcuts either. Ctrl+S
and the vertical align
+ * shortcuts still run.
+ *
+ * <p>{@code widgets.Event} and {@code events.KeyEvent} are not the same
type, so callers pass the
+ * fields. {@link TextEditing#stopShortcuts} means do not run a Hop
shortcut. {@link
+ * TextEditing#consume} means set {@code doit} false (the line was copied or
cut here).
+ */
+ private TextEditing applyTextEditingKey(
+ Widget widget, int keyCode, int stateMask, char character, Display
display) {
+ if (widget == null || widget.isDisposed()) {
+ return TextEditing.PASS;
+ }
+ // The terminal handles every key itself, including word movement and copy.
+ if (widget instanceof Control control && isInTerminalWidget(control)) {
+ return TextEditing.PASS;
+ }
+ boolean textLike = isTextLikeWidget(widget);
+ boolean webEditor = !textLike && isWebTextEditorFocused(display);
+ if (!textLike && !webEditor) {
+ return TextEditing.PASS;
+ }
+ if (isHorizontalWordKey(keyCode, stateMask)) {
+ return TextEditing.STOP;
+ }
+ if (webEditor && isCopyOrCutKey(keyCode, stateMask)) {
+ // Monaco already copied or cut. Do not also copy the graph, and do not
cancel the key.
+ return TextEditing.STOP;
+ }
+ if (!textLike || !isNativeTextEditingKey(keyCode, stateMask, character)) {
+ return TextEditing.PASS;
+ }
+ // Hop Web copies the line in text-line-clipboard.js. A server clipboard
write is outside the
+ // key gesture and would also cut the line a second time if the script did
not stop the event.
+ if (!EnvironmentUtils.getInstance().isWeb()
+ && isCopyOrCutKey(keyCode, stateMask)
+ && TextLineClipboard.copyOrCutCurrentLine(widget, isCutKey(keyCode))) {
+ return TextEditing.CONSUME;
+ }
+ return TextEditing.STOP;
+ }
+
+ /** Whether shortcut dispatch should stop, and whether the key event itself
is consumed. */
+ private static final class TextEditing {
+ private static final TextEditing PASS = new TextEditing(false, false);
+ private static final TextEditing STOP = new TextEditing(true, false);
+ private static final TextEditing CONSUME = new TextEditing(true, true);
+
+ private final boolean stopShortcuts;
+ private final boolean consume;
+
+ private TextEditing(boolean stopShortcuts, boolean consume) {
+ this.stopShortcuts = stopShortcuts;
+ this.consume = consume;
+ }
+ }
+
+ private static boolean isWebTextEditorFocused(Display display) {
+ try {
+ if (display == null || display.isDisposed()) {
+ return false;
+ }
+ return display.getData(HopGui.TEXT_EDITOR_FOCUS_DATA) != null;
+ } catch (SWTException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Left/Right with Ctrl, Command or Alt, and optionally Shift. Ctrl+Alt is
file navigation, not
+ * word movement, so it is not included.
+ */
+ private static boolean isHorizontalWordKey(int keyCode, int stateMask) {
+ int code = keyCode & SWT.KEY_MASK;
+ if (code != SWT.ARROW_LEFT && code != SWT.ARROW_RIGHT) {
+ return false;
+ }
+ boolean alt = (stateMask & SWT.ALT) != 0;
+ boolean control = (stateMask & SWT.CONTROL) != 0;
+ boolean command = (stateMask & SWT.COMMAND) != 0;
+ if (alt && (control || command)) {
+ return false;
+ }
+ return alt || control || command;
+ }
+
+ /** Ctrl/Cmd+C or Ctrl/Cmd+X with no Alt and no Shift. */
+ private static boolean isCopyOrCutKey(int keyCode, int stateMask) {
+ if ((stateMask & (SWT.ALT | SWT.SHIFT)) != 0) {
+ return false;
+ }
+ if ((stateMask & (SWT.CONTROL | SWT.COMMAND)) == 0) {
+ return false;
+ }
+ char key = Character.toLowerCase((char) keyCode);
+ return key == 'c' || key == 'x';
+ }
+
+ private static boolean isCutKey(int keyCode) {
+ return Character.toLowerCase((char) keyCode) == 'x';
+ }
+
/**
* Keys that text-like widgets must handle themselves:
copy/cut/paste/select-all,
* delete/backspace, caret / selection navigation (arrows, home/end, page
up/down) without
@@ -485,55 +620,56 @@ public class HopGuiKeyHandler extends KeyAdapter {
*
* <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.
+ * Ctrl+S) still run, except the horizontal word-movement keys handled above.
*/
- private static boolean isNativeTextEditingKey(KeyEvent event) {
- if ((event.stateMask & (SWT.CONTROL | SWT.COMMAND)) != 0) {
- char key = Character.toLowerCase((char) event.keyCode);
+ private static boolean isNativeTextEditingKey(int keyCode, int stateMask,
char character) {
+ if ((stateMask & (SWT.CONTROL | SWT.COMMAND)) != 0) {
+ char key = Character.toLowerCase((char) keyCode);
if (key == 'a' || key == 'c' || key == 'v' || key == 'x') {
return true;
}
}
- if (event.keyCode == SWT.DEL || event.character == SWT.BS) {
+ if (keyCode == SWT.DEL || character == SWT.BS) {
return true;
}
- if (isCaretNavigationKey(event)) {
+ if (isCaretNavigationKey(keyCode, stateMask)) {
return true;
}
- return isUnmodifiedPrintableCharacter(event);
+ return isUnmodifiedPrintableCharacter(keyCode, stateMask, character);
}
/**
* 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) {
+ private static boolean isUnmodifiedPrintableCharacter(
+ int keyCode, int stateMask, char character) {
+ if ((stateMask & (SWT.CONTROL | SWT.COMMAND | SWT.ALT)) != 0) {
return false;
}
- if (event.keyCode == SWT.SPACE || event.character == ' ') {
+ if (keyCode == SWT.SPACE || character == ' ') {
return true;
}
- return event.character >= 32 && event.character != SWT.DEL;
+ return character >= 32 && character != SWT.DEL;
}
/**
* 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) {
+ private static boolean isCaretNavigationKey(int keyCode, int stateMask) {
+ if ((stateMask & (SWT.CONTROL | SWT.COMMAND | SWT.ALT)) != 0) {
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;
+ int code = keyCode & SWT.KEY_MASK;
+ return code == SWT.ARROW_LEFT
+ || code == SWT.ARROW_RIGHT
+ || code == SWT.ARROW_UP
+ || code == SWT.ARROW_DOWN
+ || code == SWT.HOME
+ || code == SWT.END
+ || code == SWT.PAGE_UP
+ || code == SWT.PAGE_DOWN;
}
/**
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/widget/TextLineClipboardTest.java
b/ui/src/test/java/org/apache/hop/ui/core/widget/TextLineClipboardTest.java
new file mode 100644
index 0000000000..590545dad9
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/widget/TextLineClipboardTest.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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.junit.jupiter.api.Test;
+
+class TextLineClipboardTest {
+
+ @Test
+ void emptyTextIsAnEmptyLine() {
+ assertArrayEquals(new int[] {0, 0}, TextLineClipboard.lineRange(null, 0));
+ assertArrayEquals(new int[] {0, 0}, TextLineClipboard.lineRange("", 0));
+ assertArrayEquals(new int[] {0, 0}, TextLineClipboard.lineRange("", -3));
+ assertArrayEquals(new int[] {0, 0}, TextLineClipboard.lineRange("", 4));
+ }
+
+ @Test
+ void singleLineHasNoInventedBreak() {
+ assertArrayEquals(new int[] {0, 5}, TextLineClipboard.lineRange("hello",
0));
+ assertArrayEquals(new int[] {0, 5}, TextLineClipboard.lineRange("hello",
3));
+ assertArrayEquals(new int[] {0, 5}, TextLineClipboard.lineRange("hello",
5));
+ assertArrayEquals(new int[] {0, 5}, TextLineClipboard.lineRange("hello",
9));
+ }
+
+ @Test
+ void lineIncludesItsBreakAndTheCaretAfterTheBreakStartsTheNextLine() {
+ String text = "ab\ncd";
+ assertArrayEquals(new int[] {0, 3}, TextLineClipboard.lineRange(text, 0));
+ assertArrayEquals(new int[] {0, 3}, TextLineClipboard.lineRange(text, 2));
+ assertArrayEquals(new int[] {3, 5}, TextLineClipboard.lineRange(text, 3));
+ assertArrayEquals(new int[] {3, 5}, TextLineClipboard.lineRange(text, 5));
+ }
+
+ @Test
+ void caretBetweenCarriageReturnAndLineFeedStaysOnThatLine() {
+ String text = "ab\r\ncd";
+ assertArrayEquals(new int[] {0, 4}, TextLineClipboard.lineRange(text, 2));
+ assertArrayEquals(new int[] {0, 4}, TextLineClipboard.lineRange(text, 3));
+ assertArrayEquals(new int[] {4, 6}, TextLineClipboard.lineRange(text, 4));
+ }
+
+ @Test
+ void blankLinesAndATrailingEmptyLine() {
+ assertArrayEquals(new int[] {0, 1}, TextLineClipboard.lineRange("\n\n",
0));
+ assertArrayEquals(new int[] {1, 2}, TextLineClipboard.lineRange("\n\n",
1));
+ assertArrayEquals(new int[] {2, 2}, TextLineClipboard.lineRange("\n\n",
2));
+ assertArrayEquals(new int[] {3, 3}, TextLineClipboard.lineRange("ab\n",
3));
+ }
+
+ @Test
+ void loneCarriageReturnIsALineBreak() {
+ assertArrayEquals(new int[] {0, 3}, TextLineClipboard.lineRange("ab\rcd",
1));
+ assertArrayEquals(new int[] {3, 5}, TextLineClipboard.lineRange("ab\rcd",
3));
+ }
+
+ @Test
+ void copySelectsTheLineThenRestoresTheCaret() {
+ FakeEditor editor = new FakeEditor("ab\ncd", 4, true, false, false);
+ assertTrue(TextLineClipboard.copyOrCutCurrentLine(editor, false));
+ assertEquals("cd", editor.copied);
+ assertEquals(4, editor.caret);
+ assertFalse(editor.cutCalled);
+ }
+
+ @Test
+ void cutRemovesTheLineAndParksTheCaretAtItsStart() {
+ FakeEditor editor = new FakeEditor("ab\ncd", 1, true, false, false);
+ assertTrue(TextLineClipboard.copyOrCutCurrentLine(editor, true));
+ assertEquals("ab\n", editor.copied);
+ assertEquals("cd", editor.text);
+ assertEquals(0, editor.caret);
+ assertTrue(editor.cutCalled);
+ }
+
+ @Test
+ void selectionPasswordAndReadOnlyCutAreLeftToTheWidget() {
+ FakeEditor selected = new FakeEditor("ab\ncd", 1, true, false, true);
+ assertFalse(TextLineClipboard.copyOrCutCurrentLine(selected, false));
+ assertNull(selected.copied);
+
+ FakeEditor password = new FakeEditor("secret", 2, true, true, false);
+ assertFalse(TextLineClipboard.copyOrCutCurrentLine(password, false));
+ assertNull(password.copied);
+
+ FakeEditor readOnly = new FakeEditor("ab\ncd", 1, false, false, false);
+ assertFalse(TextLineClipboard.copyOrCutCurrentLine(readOnly, true));
+ assertEquals("ab\ncd", readOnly.text);
+ assertTrue(TextLineClipboard.copyOrCutCurrentLine(readOnly, false));
+ assertEquals("ab\n", readOnly.copied);
+ }
+
+ /** Records copy/cut without an SWT display. */
+ private static final class FakeEditor implements TextLineClipboard.Editor {
+ private String text;
+ private int caret;
+ private final boolean editable;
+ private final boolean password;
+ private final boolean selection;
+ private String copied;
+ private boolean cutCalled;
+
+ private FakeEditor(
+ String text, int caret, boolean editable, boolean password, boolean
selection) {
+ this.text = text;
+ this.caret = caret;
+ this.editable = editable;
+ this.password = password;
+ this.selection = selection;
+ }
+
+ @Override
+ public boolean isPassword() {
+ return password;
+ }
+
+ @Override
+ public boolean isEditable() {
+ return editable;
+ }
+
+ @Override
+ public boolean hasSelection() {
+ return selection;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+
+ @Override
+ public int getCaret() {
+ return caret;
+ }
+
+ @Override
+ public void select(int start, int end) {
+ copied = text.substring(start, end);
+ }
+
+ @Override
+ public void copy() {}
+
+ @Override
+ public void cut() {
+ cutCalled = true;
+ int[] range = TextLineClipboard.lineRange(text, caret);
+ text = text.substring(0, range[0]) + text.substring(range[1]);
+ caret = range[0];
+ }
+
+ @Override
+ public void setCaret(int offset) {
+ caret = offset;
+ }
+ }
+}
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 8b125c2cbb..74069f36e4 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
@@ -162,6 +162,110 @@ class HopGuiKeyHandlerTest {
}
}
+ /** Stands in for HopGui align / distribute shortcuts, which share chords
with word movement. */
+ public static class AlignGraph {
+ public int alignLeft;
+ public int distributeRight;
+ public int previousFile;
+ public int copies;
+
+ @GuiKeyboardShortcut(control = true, key = SWT.ARROW_LEFT)
+ @GuiOsxKeyboardShortcut(command = true, key = SWT.ARROW_LEFT)
+ public void alignLeft() {
+ alignLeft++;
+ }
+
+ @GuiKeyboardShortcut(alt = true, key = SWT.ARROW_RIGHT)
+ @GuiOsxKeyboardShortcut(alt = true, key = SWT.ARROW_RIGHT)
+ public void distributeRight() {
+ distributeRight++;
+ }
+
+ @GuiKeyboardShortcut(control = true, alt = true, key = SWT.ARROW_LEFT)
+ @GuiOsxKeyboardShortcut(command = true, alt = true, key = SWT.ARROW_LEFT)
+ public void previousFile() {
+ previousFile++;
+ }
+
+ @GuiKeyboardShortcut(control = true, key = 'c')
+ @GuiOsxKeyboardShortcut(command = true, key = 'c')
+ public void copySelected() {
+ copies++;
+ }
+ }
+
+ @Test
+ void horizontalWordKeysStayInTextWidgets() {
+ AlignGraph graph = new AlignGraph();
+ registerShortcutsLikeHopGuiEnvironment(AlignGraph.class);
+
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ keyHandler.addParentObjectToHandle(graph);
+ try {
+ KeyEvent inText = keyEvent(mock(Text.class), SWT.ARROW_LEFT,
SWT.CONTROL);
+ keyHandler.keyPressed(inText);
+ assertEquals(0, graph.alignLeft, "Ctrl+Left in a text field must not
align");
+ assertTrue(inText.doit, "Ctrl+Left must stay with the text widget");
+
+ KeyEvent shiftInText = keyEvent(mock(Text.class), SWT.ARROW_RIGHT,
SWT.CONTROL | SWT.SHIFT);
+ keyHandler.keyPressed(shiftInText);
+ assertTrue(shiftInText.doit, "Shift+Ctrl+Right selects by word and must
not be consumed");
+
+ KeyEvent commandInText = keyEvent(mock(Text.class), SWT.ARROW_LEFT,
SWT.COMMAND);
+ keyHandler.keyPressed(commandInText);
+ assertEquals(0, graph.alignLeft);
+ assertTrue(commandInText.doit, "Command+Left stays in the text field
(line edge on macOS)");
+
+ KeyEvent altInText = keyEvent(mock(Text.class), SWT.ARROW_RIGHT,
SWT.ALT);
+ keyHandler.keyPressed(altInText);
+ assertEquals(0, graph.distributeRight, "Alt+Right in a text field must
not distribute");
+ assertTrue(altInText.doit);
+
+ KeyEvent onCanvas = canvasKey(SWT.ARROW_LEFT, SWT.CONTROL);
+ keyHandler.keyPressed(onCanvas);
+ assertEquals(1, graph.alignLeft, "Ctrl+Left on the canvas still aligns");
+ assertFalse(onCanvas.doit);
+
+ KeyEvent fileNav = keyEvent(mock(Text.class), SWT.ARROW_LEFT,
SWT.CONTROL | SWT.ALT);
+ keyHandler.keyPressed(fileNav);
+ assertEquals(1, graph.previousFile, "Ctrl+Alt+Left is file navigation,
not word movement");
+ } finally {
+ keyHandler.removeParentObjectToHandle(graph);
+ }
+ }
+
+ @Test
+ void emptySelectionCopyDoesNotCopyTheGraph() {
+ AlignGraph graph = new AlignGraph();
+ registerShortcutsLikeHopGuiEnvironment(AlignGraph.class);
+
+ HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+ keyHandler.addParentObjectToHandle(graph);
+ try {
+ Text text = mock(Text.class);
+ when(text.getSelectionCount()).thenReturn(3);
+ KeyEvent selected = keyEvent(text, 'c', SWT.CONTROL);
+ keyHandler.keyPressed(selected);
+ assertEquals(0, graph.copies, "Ctrl+C in a text field must not copy the
graph");
+ assertTrue(selected.doit, "A selection is copied by the widget itself");
+ verify(text, never()).copy();
+
+ Text empty = mock(Text.class);
+ when(empty.getText()).thenReturn("ab\ncd");
+ when(empty.getCaretPosition()).thenReturn(0);
+ when(empty.getEditable()).thenReturn(true);
+ KeyEvent line = keyEvent(empty, 'c', SWT.CONTROL);
+ keyHandler.keyPressed(line);
+ assertEquals(0, graph.copies);
+ assertFalse(line.doit, "Copying the current line consumes the key");
+ verify(empty).setSelection(0, 3);
+ verify(empty).copy();
+ verify(empty).setSelection(0);
+ } finally {
+ keyHandler.removeParentObjectToHandle(graph);
+ }
+ }
+
@Test
void arrowKeysAreLeftToTablesAndTrees() {
NavigationGraph graph = new NavigationGraph();