You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/10/09 15:36:20 UTC
(hop) branch main updated: Issue #8653 : Indent selected lines in multi-line text fields (#8799)
This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 4402a9054d Issue #8653 : Indent selected lines in multi-line text fields (#8799)
4402a9054d is described below
commit 4402a9054d801c771cbd7304deac8a3c09bb3221
Author: Matt Casters <ma...@gmail.com>
AuthorDate: Fri Oct 9 17:36:13 2026 +0200
Issue #8653 : Indent selected lines in multi-line text fields (#8799)
* Issue #8653 : Indent selected lines in multi-line text fields
* Issue #8653 : Do not indent a tab key that was already handled
* Issue #8653 : Preserve tab indents and let Esc then Tab move focus
* Issue #8653 : Stop Esc from closing a dialog text field
---
core/src/main/java/org/apache/hop/core/Const.java | 11 +
.../modules/ROOT/pages/hop-gui/shortcuts.adoc | 3 +
.../modules/ROOT/pages/variables.adoc | 1 +
.../hop/ui/hopgui/ContentEditorFacadeImpl.java | 2 +
.../main/java/org/apache/hop/ui/hopgui/HopWeb.java | 3 +-
.../org/apache/hop/ui/hopgui/HopWebEntryPoint.java | 7 +
.../org/apache/hop/ui/hopgui/monaco-editor.js | 6 +
.../org/apache/hop/ui/hopgui/text-indent.js | 285 ++++++++++++++
.../apache/hop/ui/hopgui/ContentEditorWidget.java | 10 +-
.../hop/ui/core/widget/TextIndentWidgetTest.java | 241 ++++++++++++
.../apache/hop/ui/core/widget/StyledTextComp.java | 1 +
.../apache/hop/ui/core/widget/StyledTextVar.java | 1 +
.../org/apache/hop/ui/core/widget/TextIndent.java | 437 +++++++++++++++++++++
.../hop/ui/core/widget/TextIndentStyled.java | 69 ++++
.../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java | 19 +-
.../apache/hop/ui/core/widget/TextIndentTest.java | 289 ++++++++++++++
16 files changed, 1380 insertions(+), 5 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/Const.java b/core/src/main/java/org/apache/hop/core/Const.java
index 19b9eb19ea..284f8fe9d9 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -953,6 +953,17 @@ public class Const {
@Variable(description = "A variable to configure Tab size")
public static final String HOP_MAX_TAB_LENGTH = "HOP_MAX_TAB_LENGTH";
+ /**
+ * Spaces inserted or removed when Tab or Shift+Tab changes the indentation of the selected lines
+ * in a multi-line text field.
+ */
+ @Variable(
+ scope = VariableScope.APPLICATION,
+ value = "2",
+ description =
+ "Number of spaces Tab inserts and Shift+Tab removes on the selected lines of a multi-line text field. A line that already starts with a tab gains another tab. Set the value to 0 to insert a tab. Useful when writing JavaScript, JSON, XML, and similar files that benefit from proper indentation to recognize file or code structures. The default is 2.")
+ public static final String HOP_TEXT_TAB_SIZE = "HOP_TEXT_TAB_SIZE";
+
/**
* A variable to configure VFS USER_DIR_IS_ROOT option: should be "true" or "false" {@linkplain
* org.apache.commons.vfs2.provider.sftp.SftpFileSystemConfigBuilder}
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 bf26bb7684..a3d57afd0e 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
@@ -34,6 +34,9 @@ Widget-local shortcuts (they apply to the focused field, not the whole window) a
|`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)
|`CTRL-ENTER` / `Cmd-Enter`|AI Assistant question field|Send the question (same as the Hop logo button)
+|`TAB`|Multi-line text field|Indent the selected lines. A line that already starts with a tab, or a `HOP_TEXT_TAB_SIZE` of `0`, gains a tab. Otherwise the indent is `HOP_TEXT_TAB_SIZE` spaces (default 2). A caret with no selection indents the current line. Single-line fields still use Tab to move to the next field
+|`SHIFT-TAB`|Multi-line text field|Remove one indent from the selected lines: a leading tab, or up to `HOP_TEXT_TAB_SIZE` leading spaces
+|`ESC` then `TAB`|Hop Web multi-line text field|Move to the next field. Esc stays in the dialog and arms the next Tab so it leaves the field instead of indenting. `ESC` then `SHIFT-TAB` moves to the previous field
|===
See xref:metadata-types/naming-scheme.adoc[Naming Scheme] for the *N* indicator, types, and the TableView bulk toolbar.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
index e0b6af0efb..36289dc86b 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
@@ -309,6 +309,7 @@ The default value is 1440 (one day).
Changing it to true will remove first and last enclosure symbol from the resulting string chunks.
|HOP_SYSTEM_HOSTNAME||You can use this variable to speed up hostname lookup.
Hostname lookup is performed by Hop so that it is capable of logging the server on which a workflow or pipeline is executed.
+|HOP_TEXT_TAB_SIZE|2|Number of spaces Tab inserts and Shift+Tab removes on the selected lines of a multi-line text field. A line that already starts with a tab gains another tab. Set the value to 0 to insert a tab on every selected line. Useful when writing JavaScript, JSON, XML, and similar files that benefit from proper indentation to recognize file or code structures. Resolved from the Hop configuration, then the environment variable, then a Java system property. See xref:hop-gui/shor [...]
|HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT|0|The maximum number of transform performance snapshots to keep in memory.
Set to 0 to keep all snapshots indefinitely (default)
|HOP_USE_NATIVE_FILE_DIALOG|N|Set this value to Y if you want to use the system file open/save dialog when browsing files
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
index f2fa20c151..34d90155c2 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
@@ -24,6 +24,7 @@ import org.apache.hop.ui.core.FormDataBuilder;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.TextIndent;
import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
import org.eclipse.rap.json.JsonObject;
import org.eclipse.rap.rwt.RWT;
@@ -106,6 +107,7 @@ public class ContentEditorFacadeImpl extends ContentEditorFacade {
Composite root = createRootComposite(parent);
Text text = new Text(root, SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL | SWT.BORDER);
+ TextIndent.attach(text);
PropsUi.setLook(text, Props.WIDGET_STYLE_FIXED);
RapContentEditorWidget widget = new RapContentEditorWidget(root, text, languageId);
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 57583f53e4..938bb94467 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
@@ -175,7 +175,8 @@ public class HopWeb implements ApplicationConfiguration {
"org/apache/hop/ui/hopgui/monaco-editor.js",
"org/apache/hop/ui/hopgui/mac-command-keys.js",
"org/apache/hop/ui/hopgui/text-line-clipboard.js",
- "org/apache/hop/ui/hopgui/text-select-all.js")
+ "org/apache/hop/ui/hopgui/text-select-all.js",
+ "org/apache/hop/ui/hopgui/text-indent.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 ff02b0e7bd..6d1a84c15f 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
@@ -36,6 +36,7 @@ import org.apache.hop.core.security.HopSecurityContext;
import org.apache.hop.history.AuditManager;
import org.apache.hop.history.AuditState;
import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.widget.TextIndent;
import org.apache.hop.ui.hopgui.canvas.CanvasGraphRegistry;
import org.apache.hop.ui.hopgui.explorer.RapExplorerFileService;
import org.apache.hop.ui.hopgui.file.shared.DrillDownGuiPlugin;
@@ -221,6 +222,12 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
jsLoader.require(resourceManager.getLocation("js/text-line-clipboard.js"));
// Ctrl/Cmd+A selects the field. CANCEL_KEYS would otherwise swallow it (issue #8606).
jsLoader.require(resourceManager.getLocation("js/text-select-all.js"));
+ // Tab and Shift+Tab indent the selected lines of a multi-line field (issue #8653).
+ jsLoader.require(resourceManager.getLocation("js/text-indent.js"));
+ JavaScriptExecutor indentSize = RWT.getClient().getService(JavaScriptExecutor.class);
+ if (indentSize != null) {
+ indentSize.execute("window.hopTextTabSize=" + TextIndent.tabSize() + ";");
+ }
// 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 64a9743488..ff3248d612 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
@@ -327,6 +327,10 @@
if (self._destroyed || !self._container || !self._container.parentNode) return;
var langForEditor = toMonacoLang(self._language);
+ var tabSize = window.hopTextTabSize;
+ if (typeof tabSize !== 'number' || tabSize < 1 || tabSize > 32) {
+ tabSize = 2;
+ }
self._editor = window.monaco.editor.create(container, {
value: self._content,
language: langForEditor,
@@ -334,6 +338,8 @@
readOnly: self._readOnly,
// Copy or cut the current line when nothing is selected, same as the other text fields.
emptySelectionClipboard: true,
+ // Width only. Detection stays on, so a Makefile or a tab-indented file keeps its own indent.
+ tabSize: tabSize,
automaticLayout: true,
scrollBeyondLastLine: false,
minimap: { enabled: true },
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/text-indent.js b/rap/src/main/resources/org/apache/hop/ui/hopgui/text-indent.js
new file mode 100644
index 0000000000..9d9834168a
--- /dev/null
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/text-indent.js
@@ -0,0 +1,285 @@
+/*
+ * 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.
+ */
+
+/**
+ * Tab and Shift+Tab indent the selected lines of a multi-line text field.
+ *
+ * The line rules match org.apache.hop.ui.core.widget.TextIndent. A line that already starts with a
+ * tab, or HOP_TEXT_TAB_SIZE 0, gains a tab; otherwise the indent is window.hopTextTabSize spaces
+ * (default 2). Monaco indents itself. Esc then Tab moves focus instead of indenting. This runs in
+ * the capture phase. Tab is stopped so the browser does not move focus and the server does not
+ * indent again. Esc is stopped in the field so a dialog shell does not close before that Tab.
+ */
+(function () {
+ 'use strict';
+
+ var DEFAULT_SIZE = 2;
+ var MAX_SIZE = 32;
+ var focusExit = null;
+
+ function tabSize() {
+ var size = window.hopTextTabSize;
+ if (typeof size !== 'number' || !isFinite(size)) {
+ return DEFAULT_SIZE;
+ }
+ size = Math.floor(size);
+ if (size === 0) {
+ return 0;
+ }
+ if (size < 1 || size > MAX_SIZE) {
+ return DEFAULT_SIZE;
+ }
+ return size;
+ }
+
+ function isTextArea(el) {
+ return !!(el && el.tagName && el.tagName.toLowerCase() === 'textarea'
+ && typeof el.selectionStart === 'number' && typeof el.selectionEnd === 'number');
+ }
+
+ function inMonaco(el) {
+ return el.closest && el.closest('.monaco-editor');
+ }
+
+ function isBreak(character) {
+ return character === '\n' || character === '\r';
+ }
+
+ function clamp(offset, length) {
+ if (offset < 0) {
+ return 0;
+ }
+ if (offset > length) {
+ return length;
+ }
+ return offset;
+ }
+
+ function normalize(text, offset) {
+ if (offset > 0 && offset < text.length
+ && text.charAt(offset - 1) === '\r' && text.charAt(offset) === '\n') {
+ return offset - 1;
+ }
+ return offset;
+ }
+
+ function lineStart(text, offset) {
+ var index = normalize(text, clamp(offset, text.length));
+ while (index > 0 && !isBreak(text.charAt(index - 1))) {
+ index--;
+ }
+ return index;
+ }
+
+ function lineContentEnd(text, offset) {
+ var index = normalize(text, clamp(offset, text.length));
+ if (index < text.length && isBreak(text.charAt(index))) {
+ return index;
+ }
+ while (index < text.length && !isBreak(text.charAt(index))) {
+ index++;
+ }
+ return index;
+ }
+
+ function isAtNextLine(text, offset) {
+ if (offset <= 0 || offset > text.length) {
+ return false;
+ }
+ if (offset < text.length && text.charAt(offset - 1) === '\r' && text.charAt(offset) === '\n') {
+ return false;
+ }
+ return isBreak(text.charAt(offset - 1));
+ }
+
+ function includedEnd(text, from, to) {
+ if (to > from && isAtNextLine(text, to)) {
+ return lineContentEnd(text, to - 1);
+ }
+ return lineContentEnd(text, to);
+ }
+
+ function breakEnd(text, offset) {
+ if (offset >= text.length) {
+ return offset;
+ }
+ var current = text.charAt(offset);
+ if (current === '\r') {
+ if (offset + 1 < text.length && text.charAt(offset + 1) === '\n') {
+ return offset + 2;
+ }
+ return offset + 1;
+ }
+ if (current === '\n') {
+ return offset + 1;
+ }
+ return offset;
+ }
+
+ function spaces(count) {
+ var pad = '';
+ for (var i = 0; i < count; i++) {
+ pad += ' ';
+ }
+ return pad;
+ }
+
+ function indentLine(line, size) {
+ if (size === 0 || (line.length > 0 && line.charAt(0) === '\t')) {
+ return '\t' + line;
+ }
+ return spaces(size) + line;
+ }
+
+ function outdentLine(line, size) {
+ if (line.length > 0 && line.charAt(0) === '\t') {
+ return line.substring(1);
+ }
+ var removed = 0;
+ var limit = Math.min(size, line.length);
+ while (removed < limit && line.charAt(removed) === ' ') {
+ removed++;
+ }
+ return line.substring(removed);
+ }
+
+ function mapPoint(already, pos, start, end, newStart, shift, outdent) {
+ if (already >= 0 || pos < start || pos > end) {
+ return already;
+ }
+ var relative = pos - start;
+ var mapped = outdent ? Math.max(0, relative - shift) : relative + shift;
+ return newStart + mapped;
+ }
+
+ function mapBreak(already, pos, start, end, newStart) {
+ if (already >= 0 || pos <= start || pos > end) {
+ return already;
+ }
+ return newStart + (pos - start);
+ }
+
+ /** Same result shape as TextIndent.edit, applied to the whole value. */
+ function indentText(text, anchor, caret, size, outdent) {
+ if (text == null) {
+ text = '';
+ }
+ if (size < 0) {
+ size = DEFAULT_SIZE;
+ }
+ var length = text.length;
+ var from = clamp(Math.min(anchor, caret), length);
+ var to = clamp(Math.max(anchor, caret), length);
+ var blockStart = lineStart(text, from);
+ var blockEnd = includedEnd(text, from, to);
+ var replacement = '';
+ var newFrom = -1;
+ var newTo = -1;
+ var cursor = blockStart;
+ while (true) {
+ var contentEnd = lineContentEnd(text, cursor);
+ if (contentEnd > blockEnd) {
+ contentEnd = blockEnd;
+ }
+ var line = text.substring(cursor, contentEnd);
+ var changed = outdent ? outdentLine(line, size) : indentLine(line, size);
+ var shift = Math.abs(changed.length - line.length);
+ var lineNew = blockStart + replacement.length;
+ newFrom = mapPoint(newFrom, from, cursor, contentEnd, lineNew, shift, outdent);
+ newTo = mapPoint(newTo, to, cursor, contentEnd, lineNew, shift, outdent);
+ replacement += changed;
+ cursor = contentEnd;
+ if (cursor >= blockEnd) {
+ break;
+ }
+ var nextBreak = breakEnd(text, cursor);
+ if (nextBreak > blockEnd) {
+ nextBreak = blockEnd;
+ }
+ var breakNew = blockStart + replacement.length;
+ newFrom = mapBreak(newFrom, from, cursor, nextBreak, breakNew);
+ newTo = mapBreak(newTo, to, cursor, nextBreak, breakNew);
+ replacement += text.substring(cursor, nextBreak);
+ cursor = nextBreak;
+ if (cursor >= blockEnd) {
+ break;
+ }
+ }
+ var delta = replacement.length - (blockEnd - blockStart);
+ if (newFrom < 0) {
+ newFrom = from + delta;
+ }
+ if (newTo < 0) {
+ newTo = to + delta;
+ }
+ var next = text.substring(0, blockStart) + replacement + text.substring(blockEnd);
+ return { text: next, selectionStart: newFrom, selectionEnd: newTo };
+ }
+
+ function canIndentElement(el) {
+ return isTextArea(el) && !inMonaco(el) && !el.readOnly && !el.disabled;
+ }
+
+ document.addEventListener('keydown', function (event) {
+ var key = event.key || '';
+ var el = event.target;
+ var escape = key === 'Escape' || key === 'Esc' || event.keyCode === 27;
+ if (escape && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
+ if (canIndentElement(el)) {
+ // Shell keydown closes a dialog that has a parent. Stop it so the next Tab can move focus.
+ focusExit = el;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ } else {
+ focusExit = null;
+ }
+ return;
+ }
+ var tab = key === 'Tab' || event.keyCode === 9;
+ if (tab && focusExit && focusExit === el && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ focusExit = null;
+ return;
+ }
+ focusExit = null;
+ if (event.ctrlKey || event.metaKey || event.altKey) {
+ return;
+ }
+ if (!tab) {
+ return;
+ }
+ if (!canIndentElement(el)) {
+ return;
+ }
+ var before = el.value || '';
+ var start = el.selectionStart;
+ var end = el.selectionEnd;
+ var next = indentText(before, start, end, tabSize(), !!event.shiftKey);
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (next.text === before && next.selectionStart === start && next.selectionEnd === end) {
+ return;
+ }
+ el.value = next.text;
+ try {
+ el.setSelectionRange(next.selectionStart, next.selectionEnd);
+ } catch (e) {
+ // The control does not expose a selection. The text is still indented.
+ }
+ // RAP syncs the text widget from the input event.
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }, true);
+})();
diff --git a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
index f1fcbbf515..20e081a2ab 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
@@ -36,6 +36,7 @@ import org.apache.hop.ui.core.gui.GuiMenuWidgets;
import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.TextIndent;
import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
@@ -218,7 +219,9 @@ public class ContentEditorWidget implements IContentEditorWidget {
new org.eclipse.swt.graphics.Color(control.getDisplay(), 120, 120, 120);
lineNumberColumn.setForeground(lineNumFg);
}
- sourceViewer.getTextWidget().setTabs(4);
+ int tabStops = TextIndent.tabSize();
+ sourceViewer.getTextWidget().setTabs(tabStops < 1 ? TextIndent.DEFAULT_SIZE : tabStops);
+ TextIndent.attach(sourceViewer.getTextWidget());
sourceViewer
.getDocument()
.addDocumentListener(
@@ -357,6 +360,11 @@ public class ContentEditorWidget implements IContentEditorWidget {
if (doc != null) {
doc.set(text);
}
+ // doc.set is an undoable insert from the empty document. The loaded text is the floor.
+ IUndoManager undoManager = sourceViewer.getUndoManager();
+ if (undoManager != null) {
+ undoManager.reset();
+ }
sourceViewer.setSelectedRange(0, 0);
sourceViewer.invalidateTextPresentation();
} finally {
diff --git a/rcp/src/test/java/org/apache/hop/ui/core/widget/TextIndentWidgetTest.java b/rcp/src/test/java/org/apache/hop/ui/core/widget/TextIndentWidgetTest.java
new file mode 100644
index 0000000000..773fd39eb6
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/core/widget/TextIndentWidgetTest.java
@@ -0,0 +1,241 @@
+/*
+ * 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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.ContentEditorFacade;
+import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Listener;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.Widget;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/** Tab and Shift+Tab in multi-line text fields, issue #8653. */
+@Tag("uitest")
+class TextIndentWidgetTest extends SwtBotTestBase {
+
+ @Test
+ void tabIndentsStyledTextBeforeTheWidgetInsertsATabCharacter() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ StyledText text = new StyledText(shell, SWT.MULTI | SWT.V_SCROLL);
+ shell.open();
+ text.setText("a\nb");
+ text.setSelection(0, 0);
+ TextIndent.attach(text);
+ // The UI suite already filters this display from HopGuiKeyHandler. A second filter indents
+ // again (two spaces, then two more).
+ HopGuiKeyHandler.getInstance().addHandledShell(display, shell);
+ String pad = " ".repeat(TextIndent.tabSize());
+
+ Event tab = key(text, SWT.NONE);
+ text.notifyListeners(SWT.KeyDown, tab);
+ assertFalse(tab.doit);
+ assertEquals(pad + "a\nb", text.getText());
+ assertEquals(pad.length(), text.getCaretOffset());
+
+ text.setSelection(0, pad.length() + 1);
+ Event shiftTab = key(text, SWT.SHIFT);
+ text.notifyListeners(SWT.KeyDown, shiftTab);
+ assertEquals("a\nb", text.getText());
+ shell.dispose();
+ }
+
+ @Test
+ void aSecondKeyFilterDoesNotIndentAgain() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ StyledText text = new StyledText(shell, SWT.MULTI);
+ shell.open();
+ text.setText("a\nb");
+ text.setSelection(0, 0);
+ HopGuiKeyHandler.getInstance().addHandledShell(display, shell);
+ Listener extra = TextIndent::handleKey;
+ display.addFilter(SWT.KeyDown, extra);
+ try {
+ Event tab = key(text, SWT.NONE);
+ text.notifyListeners(SWT.KeyDown, tab);
+ assertEquals(" ".repeat(TextIndent.tabSize()) + "a\nb", text.getText());
+ } finally {
+ display.removeFilter(SWT.KeyDown, extra);
+ shell.dispose();
+ }
+ }
+
+ @Test
+ void shiftTabStaysInAnEditableStyledText() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ StyledText text = new StyledText(shell, SWT.MULTI);
+ shell.open();
+ TextIndent.attach(text);
+
+ Event shiftTab = new Event();
+ shiftTab.widget = text;
+ shiftTab.detail = SWT.TRAVERSE_TAB_PREVIOUS;
+ shiftTab.stateMask = SWT.SHIFT;
+ shiftTab.doit = true;
+ text.notifyListeners(SWT.Traverse, shiftTab);
+ assertFalse(shiftTab.doit, "Shift+Tab must not leave an editable multi-line field");
+ assertEquals(SWT.TRAVERSE_NONE, shiftTab.detail);
+
+ text.setEditable(false);
+ Event leaving = new Event();
+ leaving.widget = text;
+ leaving.detail = SWT.TRAVERSE_TAB_PREVIOUS;
+ leaving.stateMask = SWT.SHIFT;
+ leaving.doit = true;
+ text.notifyListeners(SWT.Traverse, leaving);
+ assertTrue(leaving.doit, "A read-only field still gives Shift+Tab back to focus traversal");
+ shell.dispose();
+ }
+
+ @Test
+ void singleLineTextKeepsTabForFocusTraversal() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ Text text = new Text(shell, SWT.SINGLE | SWT.BORDER);
+ shell.open();
+ text.setText("name");
+ text.setSelection(1, 1);
+ TextIndent.attach(text);
+
+ Event tab = key(text, SWT.NONE);
+ assertFalse(TextIndent.handleKey(tab));
+ assertTrue(tab.doit);
+ assertEquals("name", text.getText());
+
+ Event traverse = new Event();
+ traverse.widget = text;
+ traverse.detail = SWT.TRAVERSE_TAB_NEXT;
+ traverse.doit = true;
+ text.notifyListeners(SWT.Traverse, traverse);
+ assertTrue(traverse.doit);
+ shell.dispose();
+ }
+
+ @Test
+ void multiLineTextIndentsAndDoesNotMarkAnEmptyOutdentAsAChange() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ Text text = new Text(shell, SWT.MULTI | SWT.BORDER);
+ shell.open();
+ text.setText("{\n}");
+ text.setSelection(0, text.getCharCount());
+ String pad = " ".repeat(TextIndent.tabSize());
+
+ Event tab = key(text, SWT.NONE);
+ assertTrue(TextIndent.handleKey(tab));
+ assertEquals(pad + "{\n" + pad + "}", text.getText());
+
+ text.setText("{\n}");
+ text.setSelection(1, 1);
+ Event shiftTab = key(text, SWT.SHIFT);
+ assertTrue(TextIndent.handleKey(shiftTab));
+ assertEquals("{\n}", text.getText());
+ shell.dispose();
+ }
+
+ @Test
+ void undoOfAnIndentStopsAtTheLoadedScript() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ StyledTextVar text =
+ new StyledTextVar(new Variables(), shell, SWT.MULTI | SWT.V_SCROLL, false, false, false);
+ shell.open();
+ String original = "function f() {\n return 1;\n}\n";
+ text.setText(original);
+ assertFalse(text.canUndo(), "Loading the script must not be an undo step");
+
+ StyledText widget = text.getTextWidget();
+ widget.setSelection(0);
+ assertTrue(TextIndent.handleKey(key(widget, SWT.NONE)));
+ assertFalse(original.equals(text.getText()));
+
+ text.undo();
+ assertEquals(original, text.getText(), "The first undo must restore the loaded script");
+ assertFalse(text.canUndo(), "Undo must not go past the loaded script");
+ shell.dispose();
+ }
+
+ @Test
+ void undoOfAnIndentStopsAtTheLoadedFile() {
+ Shell shell = new Shell(display);
+ shell.setLayout(new FillLayout());
+ shell.setSize(600, 400);
+ IContentEditorWidget editor = ContentEditorFacade.createContentEditor(shell, "javascript");
+ shell.open();
+ String original = "{\n \"name\": \"hop\"\n}\n";
+ editor.setText(original);
+ editor.undo();
+ assertEquals(original, editor.getText(), "Loading the file must not be an undo step");
+
+ if (!(editor.getControl() instanceof Composite composite)) {
+ throw new AssertionError("editor control");
+ }
+ StyledText widget = findStyledText(composite);
+ widget.setFocus();
+ widget.setSelection(0);
+ assertTrue(TextIndent.handleKey(key(widget, SWT.NONE)));
+ assertFalse(original.equals(editor.getText()));
+
+ editor.undo();
+ assertEquals(original, editor.getText(), "The first undo must restore the loaded file");
+ editor.undo();
+ assertEquals(original, editor.getText(), "Undo must not clear the loaded file");
+ shell.dispose();
+ }
+
+ private static StyledText findStyledText(Composite composite) {
+ for (org.eclipse.swt.widgets.Control child : composite.getChildren()) {
+ if (child instanceof StyledText styledText) {
+ return styledText;
+ }
+ if (child instanceof Composite nested) {
+ StyledText found = findStyledText(nested);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static Event key(Widget widget, int stateMask) {
+ Event event = new Event();
+ event.widget = widget;
+ event.keyCode = SWT.TAB;
+ event.character = '\t';
+ event.stateMask = stateMask;
+ event.doit = true;
+ return event;
+ }
+}
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 5f58fc6268..7430fab1c0 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
@@ -111,6 +111,7 @@ public class StyledTextComp extends TextComposite {
super(parent, SWT.NONE, toolbarEnabled, styleType);
textWidget = new Text(this, args);
+ TextIndent.attach(textWidget);
popupMenu = new Menu(parent.getShell(), SWT.POP_UP);
buildingStyledTextMenu(popupMenu);
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 2b2a67aaf1..4713da8e09 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
@@ -134,6 +134,7 @@ public class StyledTextVar extends TextComposite {
wText = new StyledText(this, style);
// This control handles Ctrl/Cmd+Z and Ctrl/Cmd+Y. The graph must not take those chords.
wText.setData(HopGuiKeyHandler.HOP_TEXT_EDITOR_HISTORY, Boolean.TRUE);
+ TextIndent.attach(wText);
wPopupMenu = new Menu(parent.getShell(), SWT.POP_UP);
buildingStyledTextMenu(wPopupMenu);
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TextIndent.java b/ui/src/main/java/org/apache/hop/ui/core/widget/TextIndent.java
new file mode 100644
index 0000000000..0a4a7811f0
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextIndent.java
@@ -0,0 +1,437 @@
+/*
+ * 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 java.util.Map;
+import java.util.Objects;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.config.HopResolvedSettings;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.Widget;
+
+/**
+ * Indents or outdents the lines touched by a selection in a multi-line text field.
+ *
+ * <p>Tab inserts one tab when {@link #tabSize()} is 0 or the line already starts with a tab.
+ * Otherwise it inserts {@link #tabSize()} spaces. Shift+Tab removes one indent: a leading tab, or
+ * up to that many leading spaces. The width comes from {@link HopResolvedSettings}: the Hop
+ * configuration, then the environment, then a system property. The default is 2. Single-line
+ * fields, read-only fields, and Hop Web are left alone. Hop Web indents in the browser.
+ */
+public final class TextIndent {
+
+ static final String ATTACHED = "HOP_TEXT_INDENT";
+
+ /** Widest indent accepted from {@code HOP_TEXT_TAB_SIZE}. Larger values fall back to 2. */
+ static final int MAX_SIZE = 32;
+
+ public static final int DEFAULT_SIZE = 2;
+
+ private static final Class<?> STYLED_TEXT = loadStyledText();
+
+ private static final int BLOCKED_MODIFIERS = SWT.CTRL | SWT.ALT | SWT.COMMAND | SWT.MOD1;
+
+ private TextIndent() {}
+
+ /**
+ * Spaces per indent, or 0 when Tab inserts a tab character. Blank or unusable values fall back to
+ * {@link #DEFAULT_SIZE}.
+ */
+ public static int tabSize() {
+ return parseSize(resolvedTabSize());
+ }
+
+ /**
+ * Hop configuration, then the environment, then {@code -D}. Hop copies the configuration value
+ * into system properties, and that copy must not hide the environment variable.
+ */
+ static String resolvedTabSize() {
+ String name = Const.HOP_TEXT_TAB_SIZE;
+ String resolved = HopResolvedSettings.resolveString(name, null);
+ Map<String, String> environment = System.getenv();
+ if (!environment.containsKey(name) || !System.getProperties().containsKey(name)) {
+ return resolved;
+ }
+ String config = HopConfig.readStringVariable(name, null);
+ if (config != null && Objects.equals(System.getProperty(name), Const.NVL(config, ""))) {
+ return environment.get(name);
+ }
+ return resolved;
+ }
+
+ static int parseSize(String raw) {
+ if (raw == null) {
+ return DEFAULT_SIZE;
+ }
+ String trimmed = raw.trim();
+ if (trimmed.isEmpty()) {
+ return DEFAULT_SIZE;
+ }
+ try {
+ int value = Integer.parseInt(trimmed);
+ if (value == 0 || (value >= 1 && value <= MAX_SIZE)) {
+ return value;
+ }
+ } catch (NumberFormatException e) {
+ // Fall back below.
+ }
+ return DEFAULT_SIZE;
+ }
+
+ /**
+ * Indent or outdent the lines covered by {@code [anchor, caret]}.
+ *
+ * <p>A selection that ends at the first column of a line does not include that line. A caret
+ * between the CR and LF of a CRLF stays on the line that break belongs to.
+ */
+ public static Edit edit(String text, int anchor, int caret, int size, boolean outdent) {
+ if (text == null) {
+ text = "";
+ }
+ if (size < 0) {
+ size = DEFAULT_SIZE;
+ }
+ int length = text.length();
+ int from = clamp(Math.min(anchor, caret), length);
+ int to = clamp(Math.max(anchor, caret), length);
+
+ int blockStart = lineStart(text, from);
+ int blockEnd = includedEnd(text, from, to);
+
+ StringBuilder replacement = new StringBuilder();
+ int newFrom = -1;
+ int newTo = -1;
+ int cursor = blockStart;
+ while (true) {
+ int contentEnd = lineContentEnd(text, cursor);
+ if (contentEnd > blockEnd) {
+ contentEnd = blockEnd;
+ }
+ String line = text.substring(cursor, contentEnd);
+ String changed = outdent ? outdentLine(line, size) : indentLine(line, size);
+ int shift = Math.abs(changed.length() - line.length());
+ int lineNew = blockStart + replacement.length();
+ newFrom = mapPoint(newFrom, from, cursor, contentEnd, lineNew, shift, outdent);
+ newTo = mapPoint(newTo, to, cursor, contentEnd, lineNew, shift, outdent);
+ replacement.append(changed);
+ cursor = contentEnd;
+ if (cursor >= blockEnd) {
+ break;
+ }
+ int breakEnd = breakEnd(text, cursor);
+ if (breakEnd > blockEnd) {
+ breakEnd = blockEnd;
+ }
+ int breakNew = blockStart + replacement.length();
+ newFrom = mapBreak(newFrom, from, cursor, breakEnd, breakNew);
+ newTo = mapBreak(newTo, to, cursor, breakEnd, breakNew);
+ replacement.append(text, cursor, breakEnd);
+ cursor = breakEnd;
+ if (cursor >= blockEnd) {
+ break;
+ }
+ }
+
+ int delta = replacement.length() - (blockEnd - blockStart);
+ if (newFrom < 0) {
+ newFrom = from + delta;
+ }
+ if (newTo < 0) {
+ newTo = to + delta;
+ }
+ return new Edit(blockStart, blockEnd - blockStart, replacement.toString(), newFrom, newTo);
+ }
+
+ /**
+ * Apply Tab or Shift+Tab to {@code event}. Hop Web returns {@code false} so the browser script
+ * can indent during the key gesture.
+ *
+ * @return {@code true} when the event was consumed
+ */
+ public static boolean handleKey(Event event) {
+ if (event == null || event.widget == null || !event.doit || isWeb()) {
+ return false;
+ }
+ if (!isPlainTab(event.keyCode, event.character, event.stateMask)) {
+ return false;
+ }
+ if (!apply(event.widget, (event.stateMask & SWT.SHIFT) != 0)) {
+ return false;
+ }
+ event.doit = false;
+ return true;
+ }
+
+ /**
+ * Keep Tab and Shift+Tab inside an editable multi-line field. Registered after the widget's own
+ * traverse listener, which would otherwise move focus on Shift+Tab.
+ */
+ public static void handleTraverse(Event event) {
+ if (event == null || event.widget == null) {
+ return;
+ }
+ if (event.detail != SWT.TRAVERSE_TAB_NEXT && event.detail != SWT.TRAVERSE_TAB_PREVIOUS) {
+ return;
+ }
+ if ((event.stateMask & BLOCKED_MODIFIERS) != 0) {
+ return;
+ }
+ if (!canIndent(event.widget)) {
+ return;
+ }
+ event.doit = false;
+ event.detail = SWT.TRAVERSE_NONE;
+ }
+
+ /** Listen for Tab traversal on a multi-line field. A second call does nothing. */
+ public static void attach(Widget widget) {
+ if (!(widget instanceof Control control)) {
+ return;
+ }
+ try {
+ if (control.isDisposed() || control.getData(ATTACHED) != null || !isMultiLine(control)) {
+ return;
+ }
+ control.setData(ATTACHED, Boolean.TRUE);
+ control.addListener(SWT.Traverse, TextIndent::handleTraverse);
+ } catch (SWTException e) {
+ // The widget was disposed while focus moved.
+ }
+ }
+
+ static boolean apply(Widget widget, boolean outdent) {
+ try {
+ if (widget instanceof Text text) {
+ return applyText(text, outdent);
+ }
+ if (isStyledText(widget)) {
+ return TextIndentStyled.apply(widget, outdent);
+ }
+ return false;
+ } catch (SWTException e) {
+ return false;
+ }
+ }
+
+ private static boolean applyText(Text text, boolean outdent) {
+ if (!canIndentText(text)) {
+ return false;
+ }
+ Point selection = text.getSelection();
+ int anchor = selection == null ? text.getCaretPosition() : selection.x;
+ int caret = selection == null ? anchor : selection.y;
+ String original = text.getText();
+ if (original == null) {
+ original = "";
+ }
+ Edit edit = edit(original, anchor, caret, tabSize(), outdent);
+ int end = edit.replaceStart() + edit.replaceLength();
+ String slice = original.substring(edit.replaceStart(), Math.min(end, original.length()));
+ if (!slice.equals(edit.replacement())) {
+ text.setSelection(edit.replaceStart(), end);
+ text.insert(edit.replacement());
+ text.setSelection(edit.selectionStart(), edit.selectionEnd());
+ } else if (anchor != edit.selectionStart() || caret != edit.selectionEnd()) {
+ text.setSelection(edit.selectionStart(), edit.selectionEnd());
+ }
+ return true;
+ }
+
+ private static boolean canIndent(Widget widget) {
+ try {
+ if (widget instanceof Text text) {
+ return canIndentText(text);
+ }
+ if (isStyledText(widget)) {
+ return TextIndentStyled.canIndent(widget);
+ }
+ return false;
+ } catch (SWTException e) {
+ return false;
+ }
+ }
+
+ private static boolean canIndentText(Text text) {
+ return !text.isDisposed()
+ && text.isEnabled()
+ && text.getEditable()
+ && (text.getStyle() & SWT.MULTI) != 0
+ && (text.getStyle() & SWT.READ_ONLY) == 0
+ && (text.getStyle() & SWT.PASSWORD) == 0;
+ }
+
+ private static boolean isMultiLine(Widget widget) {
+ if (widget instanceof Text text) {
+ return (text.getStyle() & SWT.MULTI) != 0;
+ }
+ return isStyledText(widget) && TextIndentStyled.isMultiLine(widget);
+ }
+
+ private static boolean isPlainTab(int keyCode, char character, int stateMask) {
+ if ((stateMask & BLOCKED_MODIFIERS) != 0) {
+ return false;
+ }
+ return keyCode == SWT.TAB || character == '\t';
+ }
+
+ private static boolean isWeb() {
+ return EnvironmentUtils.getInstance().isWeb();
+ }
+
+ private static boolean isStyledText(Widget widget) {
+ return STYLED_TEXT != null && STYLED_TEXT.isInstance(widget);
+ }
+
+ private static Class<?> loadStyledText() {
+ try {
+ return Class.forName("org.eclipse.swt.custom.StyledText");
+ } catch (ClassNotFoundException e) {
+ return null;
+ }
+ }
+
+ private static String indentLine(String line, int size) {
+ if (size == 0 || (!line.isEmpty() && line.charAt(0) == '\t')) {
+ return "\t" + line;
+ }
+ return " ".repeat(size) + line;
+ }
+
+ private static String outdentLine(String line, int size) {
+ if (!line.isEmpty() && line.charAt(0) == '\t') {
+ return line.substring(1);
+ }
+ int spaces = 0;
+ int limit = Math.min(size, line.length());
+ while (spaces < limit && line.charAt(spaces) == ' ') {
+ spaces++;
+ }
+ return line.substring(spaces);
+ }
+
+ private static int mapPoint(
+ int already, int pos, int start, int end, int newStart, int shift, boolean outdent) {
+ if (already >= 0 || pos < start || pos > end) {
+ return already;
+ }
+ int relative = pos - start;
+ int mapped = outdent ? Math.max(0, relative - shift) : relative + shift;
+ return newStart + mapped;
+ }
+
+ private static int mapBreak(int already, int pos, int start, int end, int newStart) {
+ if (already >= 0 || pos <= start || pos > end) {
+ return already;
+ }
+ return newStart + (pos - start);
+ }
+
+ private static int includedEnd(String text, int from, int to) {
+ if (to > from && isAtNextLine(text, to)) {
+ return lineContentEnd(text, to - 1);
+ }
+ return lineContentEnd(text, to);
+ }
+
+ private static boolean isAtNextLine(String text, int offset) {
+ if (offset <= 0 || offset > text.length()) {
+ return false;
+ }
+ if (offset < text.length() && text.charAt(offset - 1) == '\r' && text.charAt(offset) == '\n') {
+ return false;
+ }
+ return isBreak(text.charAt(offset - 1));
+ }
+
+ private static int lineStart(String text, int offset) {
+ int index = normalize(text, clamp(offset, text.length()));
+ while (index > 0 && !isBreak(text.charAt(index - 1))) {
+ index--;
+ }
+ return index;
+ }
+
+ private static int lineContentEnd(String text, int offset) {
+ int index = normalize(text, clamp(offset, text.length()));
+ if (index < text.length() && isBreak(text.charAt(index))) {
+ return index;
+ }
+ while (index < text.length() && !isBreak(text.charAt(index))) {
+ index++;
+ }
+ return index;
+ }
+
+ private static int breakEnd(String text, int offset) {
+ if (offset >= text.length()) {
+ return offset;
+ }
+ char current = text.charAt(offset);
+ if (current == '\r') {
+ int next = offset + 1;
+ if (next < text.length() && text.charAt(next) == '\n') {
+ return next + 1;
+ }
+ return next;
+ }
+ if (current == '\n') {
+ return offset + 1;
+ }
+ return offset;
+ }
+
+ /** A caret between CR and LF still belongs to the line that ends with that break. */
+ private static int normalize(String text, int offset) {
+ if (offset > 0
+ && offset < text.length()
+ && text.charAt(offset - 1) == '\r'
+ && text.charAt(offset) == '\n') {
+ return offset - 1;
+ }
+ return offset;
+ }
+
+ private static boolean isBreak(char character) {
+ return character == '\n' || character == '\r';
+ }
+
+ private static int clamp(int offset, int length) {
+ if (offset < 0) {
+ return 0;
+ }
+ if (offset > length) {
+ return length;
+ }
+ return offset;
+ }
+
+ /** Replacement of {@code [replaceStart, replaceStart + replaceLength)} and the new selection. */
+ public record Edit(
+ int replaceStart,
+ int replaceLength,
+ String replacement,
+ int selectionStart,
+ int selectionEnd) {}
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TextIndentStyled.java b/ui/src/main/java/org/apache/hop/ui/core/widget/TextIndentStyled.java
new file mode 100644
index 0000000000..a76fc3ffa7
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TextIndentStyled.java
@@ -0,0 +1,69 @@
+/*
+ * 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.custom.StyledText;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.widgets.Widget;
+
+/**
+ * StyledText half of {@link TextIndent}. Kept in its own class so Hop Web, which has no StyledText,
+ * does not load this class.
+ */
+final class TextIndentStyled {
+
+ private TextIndentStyled() {}
+
+ static boolean isMultiLine(Widget widget) {
+ return widget instanceof StyledText styledText && (styledText.getStyle() & SWT.SINGLE) == 0;
+ }
+
+ static boolean canIndent(Widget widget) {
+ if (!(widget instanceof StyledText styledText)) {
+ return false;
+ }
+ return !styledText.isDisposed()
+ && styledText.isEnabled()
+ && styledText.getEditable()
+ && (styledText.getStyle() & SWT.SINGLE) == 0;
+ }
+
+ static boolean apply(Widget widget, boolean outdent) {
+ if (!(widget instanceof StyledText styledText) || !canIndent(styledText)) {
+ return false;
+ }
+ Point selection = styledText.getSelection();
+ int anchor = selection == null ? styledText.getCaretOffset() : selection.x;
+ int caret = selection == null ? anchor : selection.y;
+ String original = styledText.getText();
+ if (original == null) {
+ original = "";
+ }
+ TextIndent.Edit edit = TextIndent.edit(original, anchor, caret, TextIndent.tabSize(), outdent);
+ int end = edit.replaceStart() + edit.replaceLength();
+ String slice = original.substring(edit.replaceStart(), Math.min(end, original.length()));
+ if (!slice.equals(edit.replacement())) {
+ styledText.replaceTextRange(edit.replaceStart(), edit.replaceLength(), edit.replacement());
+ styledText.setSelection(edit.selectionStart(), edit.selectionEnd());
+ } else if (anchor != edit.selectionStart() || caret != edit.selectionEnd()) {
+ styledText.setSelection(edit.selectionStart(), edit.selectionEnd());
+ }
+ return true;
+ }
+}
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 24dbca717e..b660b3c991 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,6 +31,7 @@ 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.TextIndent;
import org.apache.hop.ui.core.widget.TextLineClipboard;
import org.apache.hop.ui.core.widget.TextSelectAll;
import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
@@ -155,11 +156,17 @@ public class HopGuiKeyHandler extends KeyAdapter {
// 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, Ctrl/Cmd+A selects the text, and an empty
- // Ctrl/Cmd+C/X copies or cuts the current line (issues #8362 and #8606).
+ // here, so word-movement keys are not stolen, Ctrl/Cmd+A selects the text, an empty
+ // Ctrl/Cmd+C/X copies or cuts the current line (issues #8362 and #8606), and Tab indents the
+ // selected lines of a multi-line text field (issue #8653).
//
if (display != null && !display.isDisposed() && filteredDisplays.add(display)) {
- display.addFilter(SWT.FocusIn, event -> attachTo(event.widget));
+ display.addFilter(
+ SWT.FocusIn,
+ event -> {
+ attachTo(event.widget);
+ TextIndent.attach(event.widget);
+ });
display.addFilter(SWT.KeyDown, this::filterTextEditingKey);
display.addListener(SWT.Dispose, e -> filteredDisplays.remove(display));
}
@@ -168,6 +175,12 @@ public class HopGuiKeyHandler extends KeyAdapter {
/** Display filter: runs before widget listeners, for shells that never got this handler. */
private void filterTextEditingKey(Event event) {
try {
+ if (event.widget instanceof Control control && isInTerminalWidget(control)) {
+ return;
+ }
+ if (TextIndent.handleKey(event)) {
+ return;
+ }
if (applyTextEditingKey(
event.widget, event.keyCode, event.stateMask, event.character, event.display)
.consume) {
diff --git a/ui/src/test/java/org/apache/hop/ui/core/widget/TextIndentTest.java b/ui/src/test/java/org/apache/hop/ui/core/widget/TextIndentTest.java
new file mode 100644
index 0000000000..17635fc963
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/widget/TextIndentTest.java
@@ -0,0 +1,289 @@
+/*
+ * 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.assertEquals;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.ui.core.widget.TextIndent.Edit;
+import org.junit.jupiter.api.Test;
+
+class TextIndentTest {
+
+ @Test
+ void caretIndentsTheCurrentLineAndMovesWithTheText() {
+ Edit edit = TextIndent.edit("hello", 2, 2, 2, false);
+ assertEquals(" hello", apply(edit, "hello"));
+ assertEquals(4, edit.selectionStart());
+ assertEquals(4, edit.selectionEnd());
+ }
+
+ @Test
+ void caretAtTheStartOfTheLineLandsAfterTheNewSpaces() {
+ Edit edit = TextIndent.edit("hello", 0, 0, 2, false);
+ assertEquals(" hello", apply(edit, "hello"));
+ assertEquals(2, edit.selectionStart());
+ }
+
+ @Test
+ void emptyTextBecomesAnIndent() {
+ Edit edit = TextIndent.edit("", 0, 0, 2, false);
+ assertEquals(" ", edit.replacement());
+ assertEquals(2, edit.selectionStart());
+ assertEquals(2, edit.selectionEnd());
+ }
+
+ @Test
+ void selectionThatEndsOnTheNextLineLeavesThatLineAlone() {
+ String text = "a\nb\nc";
+ Edit edit = TextIndent.edit(text, 0, 4, 2, false);
+ assertEquals(" a\n b\nc", apply(edit, text));
+ Edit again =
+ TextIndent.edit(apply(edit, text), edit.selectionStart(), edit.selectionEnd(), 2, false);
+ assertEquals(" a\n b\nc", apply(again, apply(edit, text)));
+ }
+
+ @Test
+ void wholeBufferSelectionIndentsEveryLine() {
+ String text = "a\nb\nc";
+ Edit edit = TextIndent.edit(text, 0, text.length(), 2, false);
+ assertEquals(" a\n b\n c", apply(edit, text));
+ }
+
+ @Test
+ void backwardsSelectionIndentsTheSameLines() {
+ String text = "a\nb";
+ Edit forward = TextIndent.edit(text, 0, text.length(), 2, false);
+ Edit backward = TextIndent.edit(text, text.length(), 0, 2, false);
+ assertEquals(forward.replacement(), backward.replacement());
+ assertEquals(apply(forward, text), apply(backward, text));
+ }
+
+ @Test
+ void partialSelectionStillIndentsTheWholeLineAndKeepsTheWord() {
+ String text = "hello";
+ Edit edit = TextIndent.edit(text, 1, 4, 2, false);
+ assertEquals(" hello", apply(edit, text));
+ assertEquals("ell", apply(edit, text).substring(edit.selectionStart(), edit.selectionEnd()));
+ }
+
+ @Test
+ void emptyLineInsideASelectionGainsSpaces() {
+ String text = "a\n\nb";
+ Edit edit = TextIndent.edit(text, 0, text.length(), 2, false);
+ assertEquals(" a\n \n b", apply(edit, text));
+ }
+
+ @Test
+ void caretOnTheTrailingEmptyLineIndentsThatLine() {
+ String text = "ab\n";
+ Edit edit = TextIndent.edit(text, text.length(), text.length(), 2, false);
+ assertEquals("ab\n ", apply(edit, text));
+ }
+
+ @Test
+ void selectionEndingOnTheTrailingEmptyLineDoesNotIndentIt() {
+ String text = "ab\n";
+ Edit edit = TextIndent.edit(text, 0, text.length(), 2, false);
+ assertEquals(" ab\n", apply(edit, text));
+ }
+
+ @Test
+ void carriageReturnLineFeedStaysIntact() {
+ String text = "a\r\nb";
+ Edit edit = TextIndent.edit(text, 0, text.length(), 2, false);
+ assertEquals(" a\r\n b", apply(edit, text));
+ }
+
+ @Test
+ void caretBetweenCarriageReturnAndLineFeedIndentsThatLine() {
+ String text = "ab\r\ncd";
+ Edit edit = TextIndent.edit(text, 3, 3, 2, false);
+ assertEquals(" ab\r\ncd", apply(edit, text));
+ }
+
+ @Test
+ void loneCarriageReturnIsALineBreak() {
+ String text = "a\rb";
+ Edit edit = TextIndent.edit(text, 0, text.length(), 2, false);
+ assertEquals(" a\r b", apply(edit, text));
+ }
+
+ @Test
+ void outdentRemovesUpToTheIndentAndClampsTheCaret() {
+ String text = " hello";
+ Edit edit = TextIndent.edit(text, 1, 1, 2, true);
+ assertEquals("hello", apply(edit, text));
+ assertEquals(0, edit.selectionStart());
+ }
+
+ @Test
+ void outdentRemovesOneLeadingTab() {
+ Edit edit = TextIndent.edit("\thello", 2, 2, 2, true);
+ assertEquals("hello", apply(edit, "\thello"));
+ assertEquals(1, edit.selectionStart());
+ }
+
+ @Test
+ void outdentStopsAfterFewerSpacesThanTheIndent() {
+ Edit edit = TextIndent.edit(" hello", 3, 3, 2, true);
+ assertEquals("hello", apply(edit, " hello"));
+ }
+
+ @Test
+ void outdentOfALineWithNoIndentChangesNothing() {
+ Edit edit = TextIndent.edit("hello", 2, 2, 2, true);
+ assertEquals("hello", edit.replacement());
+ assertEquals(0, edit.replaceStart());
+ assertEquals(2, edit.selectionStart());
+ }
+
+ @Test
+ void outdentDoesNotRemoveSpacesAfterTheFirstTab() {
+ Edit edit = TextIndent.edit("\t x", 0, 4, 2, true);
+ assertEquals(" x", apply(edit, "\t x"));
+ }
+
+ @Test
+ void configuredWidthIsUsedInsteadOfTwo() {
+ Edit edit = TextIndent.edit("a\nb", 0, 3, 4, false);
+ assertEquals(" a\n b", apply(edit, "a\nb"));
+ }
+
+ @Test
+ void lineThatStartsWithATabGainsAnotherTab() {
+ String text = "\thello";
+ Edit edit = TextIndent.edit(text, 2, 2, 2, false);
+ assertEquals("\t\thello", apply(edit, text));
+ assertEquals(3, edit.selectionStart());
+ }
+
+ @Test
+ void mixedLinesKeepATabOnlyWhereTheLineAlreadyHasOne() {
+ String text = "\ta\nb";
+ Edit edit = TextIndent.edit(text, 0, text.length(), 2, false);
+ assertEquals("\t\ta\n b", apply(edit, text));
+ }
+
+ @Test
+ void zeroWidthInsertsATab() {
+ String text = "hello";
+ Edit edit = TextIndent.edit(text, 0, 0, 0, false);
+ assertEquals("\thello", apply(edit, text));
+ assertEquals(1, edit.selectionStart());
+
+ Edit outdent = TextIndent.edit("\thello", 1, 1, 0, true);
+ assertEquals("hello", apply(outdent, "\thello"));
+ Edit spaces = TextIndent.edit(" hello", 2, 2, 0, true);
+ assertEquals(" hello", apply(spaces, " hello"));
+ }
+
+ @Test
+ void systemPropertyWinsOverTheEnvironmentAndUnusableValuesFallBack() {
+ assertEquals(0, TextIndent.parseSize("0"));
+ assertEquals(0, TextIndent.parseSize(" 0 "));
+ assertEquals(1, TextIndent.parseSize("1"));
+ assertEquals(32, TextIndent.parseSize(" 32 "));
+ assertEquals(4, TextIndent.parseSize("4"));
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.parseSize(null));
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.parseSize(""));
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.parseSize(" "));
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.parseSize("nope"));
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.parseSize("-3"));
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.parseSize("33"));
+
+ String name = Const.HOP_TEXT_TAB_SIZE;
+ String previousProperty = System.getProperty(name);
+ String previousEnvironment = System.getenv(name);
+ DescribedVariable previousConfig = HopConfig.getInstance().findDescribedVariable(name);
+ boolean hadConfig = previousConfig != null;
+ String previousConfigValue = hadConfig ? previousConfig.getValue() : null;
+ String previousConfigDescription = hadConfig ? previousConfig.getDescription() : null;
+ try {
+ setEnvironment(name, "4");
+ HopConfig.getInstance().setDescribedVariable(new DescribedVariable(name, "2", "test"));
+ System.setProperty(name, "8");
+ assertEquals(8, TextIndent.tabSize());
+
+ System.setProperty(name, "2");
+ assertEquals(4, TextIndent.tabSize());
+
+ System.clearProperty(name);
+ assertEquals(4, TextIndent.tabSize());
+
+ restoreEnvironment(name, null);
+ assertEquals(2, TextIndent.tabSize());
+
+ System.setProperty(name, "nope");
+ assertEquals(TextIndent.DEFAULT_SIZE, TextIndent.tabSize());
+ System.setProperty(name, "0");
+ assertEquals(0, TextIndent.tabSize());
+ } finally {
+ if (previousProperty == null) {
+ System.clearProperty(name);
+ } else {
+ System.setProperty(name, previousProperty);
+ }
+ restoreEnvironment(name, previousEnvironment);
+ if (hadConfig) {
+ HopConfig.getInstance()
+ .setDescribedVariable(
+ new DescribedVariable(name, previousConfigValue, previousConfigDescription));
+ } else {
+ HopConfig.getInstance()
+ .getDescribedVariables()
+ .removeIf(variable -> name.equals(variable.getName()));
+ }
+ }
+ }
+
+ private static String apply(Edit edit, String text) {
+ return text.substring(0, edit.replaceStart())
+ + edit.replacement()
+ + text.substring(edit.replaceStart() + edit.replaceLength());
+ }
+
+ private static void setEnvironment(String name, String value) {
+ environmentMap().put(name, value);
+ }
+
+ private static void restoreEnvironment(String name, String previous) {
+ Map<String, String> environment = environmentMap();
+ if (previous == null) {
+ environment.remove(name);
+ } else {
+ environment.put(name, previous);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, String> environmentMap() {
+ try {
+ Map<String, String> view = System.getenv();
+ Field field = view.getClass().getDeclaredField("m");
+ field.setAccessible(true);
+ return (Map<String, String>) field.get(view);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}