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();

Reply via email to