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 6dbab9a740 issue #7549 : content editor toolbar/context menu (help for
#7550) (#7576)
6dbab9a740 is described below
commit 6dbab9a74046d042e87b93e7535af119f43b4296
Author: Matt Casters <[email protected]>
AuthorDate: Mon Jul 20 12:43:37 2026 +0200
issue #7549 : content editor toolbar/context menu (help for #7550) (#7576)
* Add a context menu and missing capability to the text editor #7549
* issue #7549 : fix content editor toolbar for RAP and Markdown preview
Unblock PR #7550 by addressing the three gaps Nicolas hit:
- RAP implements getLanguage() and builds the shared ContentEditor
toolbar so plugin actions (Markdown preview) appear in hop-web.
- BaseGuiWidgets resolves static toolbar listeners with assignable
parameter types (interface/facade), so
previewMarkdown(IContentEditorWidget)
works when the registered instance is ContentEditorWidget or RAP widgets.
- Markdown preview is a static listener taking IContentEditorWidget;
dedupe content-editor i18n keys.
* issue #7549 : shared content-editor toolbar actions for Hop Web
Move ContentEditor-Toolbar @GuiToolbarElement handlers to a ui-module
GuiPlugin (ContentEditorActions) so Hop Web registers undo/copy/etc.
without loading hop-ui-rcp.
- Extend IContentEditorWidget with cut/paste/undo/redo
- RCP ContentEditorWidget keeps context menu + keyboard shortcuts
- RAP implements new ops (Text cut/paste; Monaco no-ops for now)
- Exclude hop-ui-rcp from Hop Web image (fixes Tomcat 404 startup)
- Skip ContentEditorWidget GuiPlugin scan on web as defense in depth
* issue #7549 : Markdown preview on Hop Web via in-app Browser dialog
Temp file:// URLs open on the client browser, so server-side temp HTML
never displays in Hop Web. Use ShowBrowserDialog for the RAP case;
desktop keeps the system browser + temp file path.
Also surface InvocationTargetException from static toolbar listeners
instead of silently falling through to a failed instance-method path.
* issue #7549 : Monaco dark theme for Hop Web content editors
Pass PropsUi dark mode into the Monaco remote widget as theme vs-dark
or vs (same pattern as canvas themeId). monaco-editor.js honors the
theme property on create and via setTheme.
---------
Co-authored-by: Nicolas Adment <[email protected]>
---
.gitignore | 1 +
docker/run-hop-web-local.sh | 7 +-
docker/web.Dockerfile | 5 +
.../transforms/types/JsonExplorerFileType.java | 2 +
.../transforms/types/BatExplorerFileType.java | 2 +
.../transforms/types/CsvExplorerFileType.java | 2 +
.../transforms/types/MarkDownExplorerFileType.java | 2 +
.../types/MarkDownExplorerFileTypeHandler.java | 95 +-
.../transforms/types/PythonExplorerFileType.java | 2 +
.../transforms/types/ShellExplorerFileType.java | 2 +
.../transforms/types/TextExplorerFileType.java | 2 +
.../hop/ui/hopgui/ContentEditorFacadeImpl.java | 163 ++-
.../org/apache/hop/ui/hopgui/monaco-editor.js | 20 +-
.../hop/ui/hopgui/ContentEditorFacadeImpl.java | 1034 +----------------
.../apache/hop/ui/hopgui/ContentEditorWidget.java | 1213 ++++++++++++++++++++
.../org/apache/hop/ui/core/gui/BaseGuiWidgets.java | 60 +-
.../core/widget/editor/IContentEditorWidget.java | 24 +
.../apache/hop/ui/hopgui/ContentEditorActions.java | 120 ++
.../apache/hop/ui/hopgui/HopGuiEnvironment.java | 6 +-
.../perspective/explorer/ExplorerPerspective.java | 5 +-
.../file/types/sql/SqlExplorerFileType.java | 2 +
.../file/types/xml/XmlExplorerFileType.java | 2 +
.../ui/hopgui/messages/messages_en_US.properties | 18 +-
.../core/gui/BaseGuiWidgetsStaticListenerTest.java | 91 ++
24 files changed, 1741 insertions(+), 1139 deletions(-)
diff --git a/.gitignore b/.gitignore
index f1160bc4c3..ef3a8dc1f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ release.properties
pom.xml.releaseBackup
*.iml
.idea
+.junie
bin
*.class
*.jar
diff --git a/docker/run-hop-web-local.sh b/docker/run-hop-web-local.sh
index 4962094206..773836d3f6 100755
--- a/docker/run-hop-web-local.sh
+++ b/docker/run-hop-web-local.sh
@@ -212,6 +212,11 @@ prepare_webapp() {
log "Overlaying latest Hop module JARs (core, engine, ui, rap)"
overlay_module_jars "${webapp}/WEB-INF/lib"
overlay_module_jars "${client_lib}"
+
+ # Do not leave the desktop RCP UI fragment on the Hop Web classpath (see
web.Dockerfile).
+ # Stripping here keeps the staged webapp correct even if Dockerfile is
bypassed.
+ rm -f "${webapp}/WEB-INF/lib"/hop-ui-rcp-*.jar
+ log "Removed hop-ui-rcp from staged webapp WEB-INF/lib (Hop Web must use
hop-ui-rap only)"
}
build_image() {
@@ -338,4 +343,4 @@ main() {
run_container
}
-main "$@"
\ No newline at end of file
+main "$@"
diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile
index 70fed1a1d9..5b768204a9 100644
--- a/docker/web.Dockerfile
+++ b/docker/web.Dockerfile
@@ -82,6 +82,11 @@ COPY ./assemblies/client/target/hop/plugins
"${CATALINA_HOME}"/plugins
COPY ./assemblies/client/target/hop/lib/jdbc/ "${CATALINA_HOME}"/jdbc-drivers
COPY --chown=hop ./docker/resources/run-web.sh /tmp/
+# Desktop (RCP) UI fragment must not ship in Hop Web. Client lib/core is
shared with the
+# desktop assembly and includes hop-ui-rcp; loading its GuiPlugins (e.g.
ContentEditorWidget)
+# fails on RAP with NoClassDefFoundError for SWT types such as
VerifyKeyListener.
+RUN rm -f "${CATALINA_HOME}"/webapps/ROOT/WEB-INF/lib/hop-ui-rcp-*.jar
+
# Fix hop-config.json
RUN sed -i 's/config\/projects/${HOP_CONFIG_FOLDER}\/projects/g'
"${CATALINA_HOME}"/webapps/ROOT/config/hop-config.json
diff --git
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
index fc19743704..c9d11197b3 100644
---
a/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
+++
b/plugins/transforms/json/src/main/java/org/apache/hop/pipeline/transforms/types/JsonExplorerFileType.java
@@ -48,6 +48,8 @@ public class JsonExplorerFileType extends
BaseTextExplorerFileType<JsonExplorerF
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
index e3d1796686..e897142597 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/BatExplorerFileType.java
@@ -48,6 +48,8 @@ public class BatExplorerFileType extends
BaseTextExplorerFileType<TextExplorerFi
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
index 0e6170ca53..30dbeb3669 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/CsvExplorerFileType.java
@@ -48,6 +48,8 @@ public class CsvExplorerFileType extends
BaseTextExplorerFileType<TextExplorerFi
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
index bf0112640f..169d6351bf 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileType.java
@@ -61,6 +61,8 @@ public class MarkDownExplorerFileType
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
index 00ec78346f..c44ea29ff8 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/MarkDownExplorerFileTypeHandler.java
@@ -23,17 +23,17 @@ import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
-import org.apache.hop.core.Props;
import org.apache.hop.core.gui.plugin.GuiPlugin;
import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementFilter;
import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementType;
+import org.apache.hop.ui.core.FormDataBuilder;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.core.dialog.ErrorDialog;
-import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
-import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.dialog.ShowBrowserDialog;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
import org.apache.hop.ui.hopgui.ContentEditorFacade;
import org.apache.hop.ui.hopgui.HopGui;
-import org.apache.hop.ui.hopgui.ToolbarFacade;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerFile;
import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
import
org.apache.hop.ui.hopgui.perspective.explorer.file.types.text.BaseTextExplorerFileTypeHandler;
@@ -45,19 +45,16 @@ import
org.commonmark.ext.task.list.items.TaskListItemsExtension;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.HtmlRenderer;
-import org.eclipse.swt.SWT;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
import org.eclipse.swt.widgets.Composite;
-import org.eclipse.swt.widgets.Control;
/** How do we handle a markdown file in the file explorer perspective? */
-@GuiPlugin
+@GuiPlugin(name = "Markdown file type handler")
public class MarkDownExplorerFileTypeHandler extends
BaseTextExplorerFileTypeHandler {
private static final Class<?> PKG = MarkDownExplorerFileType.class;
- public static final String TOOLBAR_PARENT_ID =
"MarkDownExplorerFileTypeHandler-Toolbar";
+ protected static final String LANGUAGE = "markdown";
+
public static final String TOOLBAR_ITEM_PREVIEW =
"MarkDownExplorerFileTypeHandler-ToolBar-Preview";
@@ -72,41 +69,15 @@ public class MarkDownExplorerFileTypeHandler extends
BaseTextExplorerFileTypeHan
@Override
protected String getLanguageId() {
- return "markdown";
+ return LANGUAGE;
}
@Override
public void renderFile(Composite composite) {
- // Create the toolbar container using the facade
- IToolbarContainer toolBarContainer =
- ToolbarFacade.createToolbarContainer(composite, SWT.WRAP | SWT.LEFT |
SWT.HORIZONTAL);
- Control toolBarControl = toolBarContainer.getControl();
-
- // Create the toolbar widgets using GuiToolbarWidgets
- GuiToolbarWidgets toolBarWidgets = new GuiToolbarWidgets();
- toolBarWidgets.registerGuiPluginObject(this);
- toolBarWidgets.createToolbarWidgets(toolBarContainer, TOOLBAR_PARENT_ID);
-
- // Layout data for the toolbar control
- FormData fdToolBar = new FormData();
- fdToolBar.left = new FormAttachment(0, 0);
- fdToolBar.right = new FormAttachment(100, 0);
- fdToolBar.top = new FormAttachment(0, 0);
- toolBarControl.setLayoutData(fdToolBar);
- toolBarControl.pack();
- PropsUi.setLook(toolBarControl, Props.WIDGET_STYLE_TOOLBAR);
- // Create the standard editor widget
+ // Shared content-editor toolbar (incl. Markdown preview) is built inside
the editor widget.
editorWidget = ContentEditorFacade.createContentEditor(composite,
getLanguageId());
-
- // Position the editor widget below the toolbar
- Control editorControl = editorWidget.getControl();
- FormData fdEditor = new FormData();
- fdEditor.left = new FormAttachment(0, 0);
- fdEditor.right = new FormAttachment(100, 0);
- fdEditor.top = new FormAttachment(toolBarControl, 0);
- fdEditor.bottom = new FormAttachment(100, 0);
- editorControl.setLayoutData(fdEditor);
+
editorWidget.getControl().setLayoutData(FormDataBuilder.builder().fullSize().build());
// If it's a new file, there's no need to reload it
if (this.getFilename() != null) {
@@ -123,13 +94,23 @@ public class MarkDownExplorerFileTypeHandler extends
BaseTextExplorerFileTypeHan
});
}
+ @GuiToolbarElementFilter(parentId =
IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID)
+ public static boolean showForMarkdownFileType(String itemId, Object
guiPluginInstance) {
+ if (TOOLBAR_ITEM_PREVIEW.equals(itemId)
+ && guiPluginInstance instanceof IContentEditorWidget editor) {
+ return LANGUAGE.equals(editor.getLanguage());
+ }
+ return true;
+ }
+
@GuiToolbarElement(
- root = TOOLBAR_PARENT_ID,
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
id = TOOLBAR_ITEM_PREVIEW,
toolTip = "i18n::MarkDownFileTypeHandler.Preview.Tooltip",
type = GuiToolbarElementType.BUTTON,
- image = "ui/images/preview.svg")
- public void previewMarkdown() {
+ image = "ui/images/preview.svg",
+ separator = true)
+ public static void previewMarkdown(IContentEditorWidget editorWidget) {
try {
String markdown = editorWidget.getText();
@@ -291,20 +272,30 @@ public class MarkDownExplorerFileTypeHandler extends
BaseTextExplorerFileTypeHan
html.append(htmlContent);
html.append("\n</body>\n</html>");
- // Create a temporary file
- File tempFile = File.createTempFile("markdown_preview_", ".html");
- tempFile.deleteOnExit();
+ String fullHtml = html.toString();
- // Write the HTML to the temp file
- try (OutputStream outputStream = new FileOutputStream(tempFile)) {
- outputStream.write(html.toString().getBytes(StandardCharsets.UTF_8));
+ // Hop Web: openUrl(file://...) points at the server temp path, which
the client browser
+ // cannot read. Show the rendered HTML in an in-app Browser dialog
instead.
+ // Desktop: keep writing a temp file and open it in the system browser.
+ if (EnvironmentUtils.getInstance().isWeb()) {
+ ShowBrowserDialog dialog =
+ new ShowBrowserDialog(
+ HopGui.getInstance().getActiveShell(), "Markdown preview",
fullHtml);
+ dialog.open();
+ } else {
+ File tempFile = File.createTempFile("markdown_preview_", ".html");
+ tempFile.deleteOnExit();
+ try (OutputStream outputStream = new FileOutputStream(tempFile)) {
+ outputStream.write(fullHtml.getBytes(StandardCharsets.UTF_8));
+ }
+ EnvironmentUtils.getInstance().openUrl(tempFile.toURI().toString());
}
-
- // Open in browser
- EnvironmentUtils.getInstance().openUrl(tempFile.toURI().toString());
} catch (Exception e) {
new ErrorDialog(
- hopGui.getShell(), "Error", "Error generating or displaying Markdown
preview", e);
+ HopGui.getInstance().getActiveShell(),
+ "Error",
+ "Error generating or displaying Markdown preview",
+ e);
}
}
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
index 3d61448134..3cef1040c7 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/PythonExplorerFileType.java
@@ -49,6 +49,8 @@ public class PythonExplorerFileType
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
index c95ae4f29c..c8ecd00dd0 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/ShellExplorerFileType.java
@@ -48,6 +48,8 @@ public class ShellExplorerFileType extends
BaseTextExplorerFileType<TextExplorer
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
index 094a47ebc2..95fbee298c 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/types/TextExplorerFileType.java
@@ -48,6 +48,8 @@ public class TextExplorerFileType extends
BaseTextExplorerFileType<TextExplorerF
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
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 c1bdd5eb40..dd6aac7a6f 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
@@ -20,7 +20,10 @@ package org.apache.hop.ui.hopgui;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.hop.core.Props;
import org.apache.hop.core.logging.LogChannel;
+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.editor.IContentEditorWidget;
import org.eclipse.rap.json.JsonObject;
import org.eclipse.rap.rwt.RWT;
@@ -30,16 +33,19 @@ import org.eclipse.rap.rwt.remote.RemoteObject;
import org.eclipse.rap.rwt.widgets.WidgetUtil;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.ModifyListener;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Text;
+import org.jspecify.annotations.Nullable;
/**
* Hop Web (RAP) implementation of the content editor. Uses Monaco Editor when
available
* (client-side JavaScript), with fallback to a plain Text widget.
+ *
+ * <p>Builds the shared {@link
IContentEditorWidget#GUI_PLUGIN_TOOLBAR_PARENT_ID} toolbar so
+ * plugin-contributed actions (e.g. Markdown preview) appear in hop-web as
well as desktop.
*/
public class ContentEditorFacadeImpl extends ContentEditorFacade {
@@ -48,14 +54,15 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
@Override
protected IContentEditorWidget createContentEditorInternal(Composite parent,
String languageId) {
try {
- Composite host = new Composite(parent, SWT.NONE);
+ Composite root = createRootComposite(parent);
+
+ // Editor host (Monaco parent) fills the area below the toolbar.
+ Composite host = new Composite(root, SWT.NONE);
PropsUi.setLook(host);
- FormData fd = new FormData();
- fd.left = new FormAttachment(0, 0);
- fd.right = new FormAttachment(100, 0);
- fd.top = new FormAttachment(0, 0);
- fd.bottom = new FormAttachment(100, 0);
- host.setLayoutData(fd);
+ // Avoid a light flash before Monaco mounts when Hop Web is in dark mode
+ if (PropsUi.getInstance().isDarkMode()) {
+
host.setBackground(host.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY));
+ }
Connection connection = RWT.getUISession().getConnection();
RemoteObject remoteObject =
connection.createRemoteObject(MONACO_REMOTE_TYPE);
@@ -63,8 +70,14 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
remoteObject.set("self", remoteObject.getId());
remoteObject.set("content", "");
remoteObject.set("language", languageId != null ? languageId :
"plaintext");
+ // Match Hop Web theme (/ui-dark → Monaco vs-dark), same idea as canvas
themeId
+ remoteObject.set("theme", PropsUi.getInstance().isDarkMode() ? "vs-dark"
: "vs");
+
+ RapMonacoEditorWidget widget =
+ new RapMonacoEditorWidget(root, host, remoteObject, languageId);
+ Control toolbar = addToolbar(root, widget);
+
host.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
- RapMonacoEditorWidget widget = new RapMonacoEditorWidget(host,
remoteObject);
remoteObject.setHandler(widget.getOperationHandler());
remoteObject.listen("contentChanged", true);
host.addListener(
@@ -79,34 +92,67 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
return widget;
} catch (Exception e) {
LogChannel.UI.logDebug("Monaco editor not available, using plain Text: "
+ e.getMessage());
- return createFallbackTextWidget(parent);
+ return createFallbackTextWidget(parent, languageId);
}
}
- private static IContentEditorWidget createFallbackTextWidget(Composite
parent) {
- Text text = new Text(parent, SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL);
+ private static IContentEditorWidget createFallbackTextWidget(
+ Composite parent, String languageId) {
+ Composite root = createRootComposite(parent);
+
+ Text text = new Text(root, SWT.MULTI | SWT.H_SCROLL | SWT.V_SCROLL |
SWT.BORDER);
PropsUi.setLook(text, Props.WIDGET_STYLE_FIXED);
- FormData fd = new FormData();
- fd.left = new FormAttachment(0, 0);
- fd.right = new FormAttachment(100, 0);
- fd.top = new FormAttachment(0, 0);
- fd.bottom = new FormAttachment(100, 0);
- text.setLayoutData(fd);
- return new RapContentEditorWidget(text);
+
+ RapContentEditorWidget widget = new RapContentEditorWidget(root, text,
languageId);
+ Control toolbar = addToolbar(root, widget);
+
text.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
+ return widget;
+ }
+
+ private static Composite createRootComposite(Composite parent) {
+ Composite root = new Composite(parent, SWT.NONE);
+ root.setLayout(new FormLayout());
+ root.setLayoutData(FormDataBuilder.builder().fullSize().build());
+ PropsUi.setLook(root);
+ return root;
+ }
+
+ /**
+ * Create the shared content-editor toolbar on {@code root}, registering
{@code widget} for
+ * toolbar filters and static listeners.
+ *
+ * @return the toolbar control
+ */
+ private static Control addToolbar(Composite root, IContentEditorWidget
widget) {
+ IToolbarContainer toolbarContainer =
+ ToolbarFacade.createToolbarContainer(root, SWT.WRAP | SWT.RIGHT |
SWT.HORIZONTAL);
+ Control toolbar = toolbarContainer.getControl();
+ toolbar.setLayoutData(FormDataBuilder.builder().top().fullWidth().build());
+ PropsUi.setLook(toolbar, Props.WIDGET_STYLE_TOOLBAR);
+
+ GuiToolbarWidgets toolbarWidgets = new GuiToolbarWidgets();
+ toolbarWidgets.registerGuiPluginObject(widget);
+ toolbarWidgets.createToolbarWidgets(
+ toolbarContainer, IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID);
+ toolbar.pack();
+ return toolbar;
}
private static class RapMonacoEditorWidget implements IContentEditorWidget {
- private final Composite control;
+ private final Composite root;
private final RemoteObject remoteObject;
private volatile String cachedContent = "";
private final java.util.List<ModifyListener> modifyListeners = new
CopyOnWriteArrayList<>();
private boolean suppressModify;
+ private volatile String languageId;
private final AbstractOperationHandler operationHandler;
- RapMonacoEditorWidget(Composite control, RemoteObject remoteObject) {
- this.control = control;
+ RapMonacoEditorWidget(
+ Composite root, Composite host, RemoteObject remoteObject, String
languageId) {
+ this.root = root;
this.remoteObject = remoteObject;
+ this.languageId = languageId != null ? languageId : "";
this.operationHandler =
new AbstractOperationHandler() {
@Override
@@ -119,13 +165,13 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
if (suppressModify) {
return;
}
- Display display = control.getDisplay();
- if (display == null || control.isDisposed()) {
+ Display display = host.getDisplay();
+ if (display == null || host.isDisposed()) {
return;
}
Runnable run =
() -> {
- if (control.isDisposed()) return;
+ if (host.isDisposed()) return;
for (ModifyListener listener : modifyListeners) {
try {
listener.modifyText(null);
@@ -149,7 +195,7 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
@Override
public Control getControl() {
- return control;
+ return root;
}
@Override
@@ -174,8 +220,14 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
}
}
+ @Override
+ public @Nullable String getLanguage() {
+ return languageId;
+ }
+
@Override
public void setLanguage(String languageId) {
+ this.languageId = languageId != null ? languageId : "";
remoteObject.set("language", languageId != null ? languageId :
"plaintext");
}
@@ -200,7 +252,7 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
@Override
public void selectAll() {
- // Monaco handles selection on client; no-op or could call a remote
method if we add one
+ // Monaco handles selection on client; no-op until a remote method is
added
}
@Override
@@ -212,16 +264,40 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
public void copy() {
// Monaco handles copy on client
}
+
+ @Override
+ public void cut() {
+ // no-op for Monaco until clipboard remote ops exist
+ }
+
+ @Override
+ public void paste() {
+ // no-op for Monaco until clipboard remote ops exist
+ }
+
+ @Override
+ public void undo() {
+ // no-op for Monaco until undo remote ops exist
+ }
+
+ @Override
+ public void redo() {
+ // no-op for Monaco until redo remote ops exist
+ }
}
private static class RapContentEditorWidget implements IContentEditorWidget {
+ private final Composite root;
private final Text text;
private final java.util.List<ModifyListener> modifyListeners = new
CopyOnWriteArrayList<>();
private boolean suppressModify;
+ private volatile String languageId;
- RapContentEditorWidget(Text text) {
+ RapContentEditorWidget(Composite root, Text text, String languageId) {
+ this.root = root;
this.text = text;
+ this.languageId = languageId != null ? languageId : "";
text.addModifyListener(
e -> {
if (suppressModify) return;
@@ -237,7 +313,7 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
@Override
public Control getControl() {
- return text;
+ return root;
}
@Override
@@ -260,9 +336,14 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
}
}
+ @Override
+ public @Nullable String getLanguage() {
+ return languageId;
+ }
+
@Override
public void setLanguage(String languageId) {
- // no-op for plain text
+ this.languageId = languageId != null ? languageId : "";
}
@Override
@@ -294,5 +375,25 @@ public class ContentEditorFacadeImpl extends
ContentEditorFacade {
public void copy() {
text.copy();
}
+
+ @Override
+ public void cut() {
+ text.cut();
+ }
+
+ @Override
+ public void paste() {
+ text.paste();
+ }
+
+ @Override
+ public void undo() {
+ // SWT Text has no standard undo API
+ }
+
+ @Override
+ public void redo() {
+ // SWT Text has no standard redo API
+ }
}
}
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 b12bb23302..61e8e87a4e 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
@@ -55,6 +55,15 @@
return map[String(lang).toLowerCase()] || "plaintext";
}
+ /** Monaco built-in themes: vs (light), vs-dark, hc-black */
+ function normalizeTheme(theme) {
+ if (!theme) return "vs";
+ var t = String(theme).toLowerCase();
+ if (t === "vs-dark" || t === "dark") return "vs-dark";
+ if (t === "hc-black" || t === "hc") return "hc-black";
+ return "vs";
+ }
+
rap.registerTypeHandler("hop.MonacoEditor", {
factory: function(properties) {
return new hop.MonacoEditor(properties);
@@ -71,7 +80,7 @@
widget._container.parentNode.removeChild(widget._container);
}
},
- properties: [ "content", "language", "readOnly" ],
+ properties: [ "content", "language", "readOnly", "theme" ],
events: [ "contentChanged" ]
});
@@ -81,6 +90,7 @@
this._content = properties.content != null ? properties.content : "";
this._language = properties.language != null ? properties.language :
"plaintext";
this._readOnly = properties.readOnly === true;
+ this._theme = normalizeTheme(properties.theme);
this._editor = null;
this._container = null;
this._parentId = properties.parent;
@@ -126,6 +136,13 @@
}
},
+ setTheme: function(theme) {
+ this._theme = normalizeTheme(theme);
+ if (this._editor && window.monaco && window.monaco.editor) {
+ window.monaco.editor.setTheme(this._theme);
+ }
+ },
+
_notifyServer: function() {
if (this._destroyed || !this._editor || this._readOnly) return;
var value = this._editor.getValue();
@@ -193,6 +210,7 @@
self._editor = window.monaco.editor.create(container, {
value: self._content,
language: langForEditor,
+ theme: self._theme,
readOnly: self._readOnly,
automaticLayout: true,
scrollBeyondLastLine: false,
diff --git
a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
index 9c63b195d3..4a5d4ab6c9 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
@@ -17,58 +17,8 @@
package org.apache.hop.ui.hopgui;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Deque;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.CopyOnWriteArrayList;
-import org.apache.hop.ui.core.PropsUi;
-import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
-import org.eclipse.jface.text.DocumentEvent;
-import org.eclipse.jface.text.IDocument;
-import org.eclipse.jface.text.IDocumentExtension3;
-import org.eclipse.jface.text.IDocumentListener;
-import org.eclipse.jface.text.ITextViewerExtension5;
-import org.eclipse.jface.text.Position;
-import org.eclipse.jface.text.rules.FastPartitioner;
-import org.eclipse.jface.text.rules.RuleBasedPartitionScanner;
-import org.eclipse.jface.text.rules.Token;
-import org.eclipse.jface.text.source.Annotation;
-import org.eclipse.jface.text.source.AnnotationModel;
-import org.eclipse.jface.text.source.AnnotationRulerColumn;
-import org.eclipse.jface.text.source.CompositeRuler;
-import org.eclipse.jface.text.source.IAnnotationAccess;
-import org.eclipse.jface.text.source.IAnnotationAccessExtension;
-import org.eclipse.jface.text.source.LineNumberRulerColumn;
-import org.eclipse.jface.text.source.SourceViewer;
-import org.eclipse.jface.text.source.SourceViewerConfiguration;
-import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
-import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel;
-import org.eclipse.jface.text.source.projection.ProjectionViewer;
-import org.eclipse.swt.SWT;
-import org.eclipse.swt.custom.CaretListener;
-import org.eclipse.swt.custom.StyleRange;
-import org.eclipse.swt.custom.StyledText;
-import org.eclipse.swt.custom.VerifyKeyListener;
-import org.eclipse.swt.events.ModifyListener;
-import org.eclipse.swt.events.MouseAdapter;
-import org.eclipse.swt.events.MouseEvent;
-import org.eclipse.swt.events.PaintEvent;
-import org.eclipse.swt.events.PaintListener;
-import org.eclipse.swt.events.VerifyEvent;
-import org.eclipse.swt.graphics.Color;
-import org.eclipse.swt.graphics.GC;
-import org.eclipse.swt.graphics.Rectangle;
-import org.eclipse.swt.layout.FormAttachment;
-import org.eclipse.swt.layout.FormData;
-import org.eclipse.swt.layout.FormLayout;
-import org.eclipse.swt.widgets.Canvas;
import org.eclipse.swt.widgets.Composite;
-import org.eclipse.swt.widgets.Control;
-import org.eclipse.swt.widgets.Display;
/**
* Desktop (RCP) implementation of the content editor using native SWT:
Eclipse JFace SourceViewer
@@ -76,990 +26,8 @@ import org.eclipse.swt.widgets.Display;
* tags.
*/
public class ContentEditorFacadeImpl extends ContentEditorFacade {
-
@Override
protected IContentEditorWidget createContentEditorInternal(Composite parent,
String languageId) {
- Composite container = new Composite(parent, SWT.NONE);
- PropsUi.setLook(container);
- container.setLayout(new FormLayout());
- FormData fd = new FormData();
- fd.left = new FormAttachment(0, 0);
- fd.right = new FormAttachment(100, 0);
- fd.top = new FormAttachment(0, 0);
- fd.bottom = new FormAttachment(100, 0);
- container.setLayoutData(fd);
-
- int styles = SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL | SWT.BORDER;
- CompositeRuler ruler = new CompositeRuler();
- ProjectionViewer sourceViewer = new ProjectionViewer(container, ruler,
null, false, styles);
- sourceViewer.setEditable(true);
- org.eclipse.jface.text.Document document = new
org.eclipse.jface.text.Document();
- RuleBasedPartitionScanner partitionScanner = new
RuleBasedPartitionScanner();
- partitionScanner.setDefaultReturnToken(new
Token(IDocument.DEFAULT_CONTENT_TYPE));
- FastPartitioner partitioner = new FastPartitioner(partitionScanner, new
String[0]);
- partitioner.connect(document);
- ((IDocumentExtension3) document)
- .setDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING,
partitioner);
- sourceViewer.setDocument(document, new AnnotationModel());
-
- SourceViewerConfiguration config =
RuleBasedSourceViewerConfiguration.create(languageId);
- if (config != null) {
- sourceViewer.configure(config);
- }
- sourceViewer.invalidateTextPresentation();
-
- // Enable projection (folding) without ProjectionSupport so we control the
ruler column
- sourceViewer.doOperation(ProjectionViewer.TOGGLE);
-
- // Line numbers first (index 0), then fold column (index 1) with explicit
width
- LineNumberRulerColumn lineNumberColumn = new LineNumberRulerColumn();
- ruler.addDecorator(0, lineNumberColumn);
-
- ProjectionAnnotationModel projModel =
sourceViewer.getProjectionAnnotationModel();
- if (projModel != null) {
- FoldingAnnotationAccess foldAccess = new FoldingAnnotationAccess();
- AnnotationRulerColumn foldColumn = new AnnotationRulerColumn(projModel,
20, foldAccess);
- foldColumn.addAnnotationType(ProjectionAnnotation.TYPE);
- ruler.addDecorator(1, foldColumn);
-
- Control foldControl = foldColumn.getControl();
- if (foldControl != null) {
- foldControl.addMouseListener(
- new MouseAdapter() {
- @Override
- public void mouseUp(MouseEvent e) {
- int line = foldColumn.toDocumentLineNumber(e.y);
- if (line < 0) return;
- IDocument doc = sourceViewer.getDocument();
- if (doc == null) return;
- java.util.Iterator<Annotation> it =
projModel.getAnnotationIterator();
- while (it.hasNext()) {
- Annotation ann = it.next();
- if (ann instanceof ProjectionAnnotation) {
- Position pos = projModel.getPosition(ann);
- if (pos != null) {
- try {
- int annLine = doc.getLineOfOffset(pos.getOffset());
- if (annLine == line) {
-
projModel.toggleExpansionState((ProjectionAnnotation) ann);
- return;
- }
- } catch (org.eclipse.jface.text.BadLocationException
ignored) {
- // ignore
- }
- }
- }
- }
- }
- });
- }
- }
-
- applyFontFromHop(sourceViewer);
- org.eclipse.swt.graphics.Font editorFont =
sourceViewer.getTextWidget().getFont();
- if (editorFont != null && !editorFont.isDisposed()) {
- lineNumberColumn.setFont(editorFont);
- }
- boolean dark = PropsUi.getInstance().isDarkMode();
- if (dark) {
- org.eclipse.swt.graphics.Color lineNumFg =
- new org.eclipse.swt.graphics.Color(container.getDisplay(), 130, 130,
130);
- org.eclipse.swt.graphics.Color lineNumBg =
- new org.eclipse.swt.graphics.Color(container.getDisplay(), 40, 40,
40);
- lineNumberColumn.setForeground(lineNumFg);
- lineNumberColumn.setBackground(lineNumBg);
- } else {
- org.eclipse.swt.graphics.Color lineNumFg =
- new org.eclipse.swt.graphics.Color(container.getDisplay(), 120, 120,
120);
- lineNumberColumn.setForeground(lineNumFg);
- }
- sourceViewer.getTextWidget().setTabs(4);
-
- Control viewerControl = sourceViewer.getControl();
- FormData fdViewer = new FormData();
- fdViewer.left = new FormAttachment(0, 0);
- fdViewer.right = new FormAttachment(100, 0);
- fdViewer.top = new FormAttachment(0, 0);
- fdViewer.bottom = new FormAttachment(100, 0);
- viewerControl.setLayoutData(fdViewer);
-
- SwtContentEditorWidget widget = new SwtContentEditorWidget(container,
sourceViewer, languageId);
- installBracketAndXmlSupport(widget);
- return widget;
- }
-
- /** Installs bracket highlighting and XML auto-closing when language is xml.
*/
- private static void installBracketAndXmlSupport(SwtContentEditorWidget
widget) {
- widget.installBracketHighlighting();
- widget.installFolding();
- widget.installBlockGuidePainter();
- widget.installCollapsePlaceholderPainter();
- widget.installXmlAutoClose();
- }
-
- private static void applyFontFromHop(SourceViewer sourceViewer) {
- try {
- org.eclipse.swt.graphics.Font swtFont =
GuiResource.getInstance().getFontFixed();
- if (swtFont != null && !swtFont.isDisposed()) {
- sourceViewer.getTextWidget().setFont(swtFont);
- }
- } catch (Exception e) {
- // GuiResource or font not available
- }
- }
-
- /** Kept for API compatibility; rule-based config uses languageId directly.
*/
- static String mapLanguageToScope(String languageId) {
- return languageId;
- }
-
- /**
- * Annotation access for the fold ruler column: paints chevron icons and
reports annotation
- * metadata to the {@link AnnotationRulerColumn}.
- */
- private static class FoldingAnnotationAccess
- implements IAnnotationAccess, IAnnotationAccessExtension {
-
- @Override
- public Object getType(Annotation annotation) {
- return annotation.getType();
- }
-
- @Override
- public boolean isMultiLine(Annotation annotation) {
- return true;
- }
-
- @Override
- public boolean isTemporary(Annotation annotation) {
- return true;
- }
-
- @Override
- public String getTypeLabel(Annotation annotation) {
- return annotation.getText() != null ? annotation.getText() : "";
- }
-
- @Override
- public int getLayer(Annotation annotation) {
- return 0;
- }
-
- @Override
- public Object[] getSupertypes(Object annotationType) {
- return new Object[0];
- }
-
- @Override
- public boolean isSubtype(Object annotationType, Object potentialSupertype)
{
- return annotationType != null &&
annotationType.equals(potentialSupertype);
- }
-
- @Override
- public boolean isPaintable(Annotation annotation) {
- return annotation instanceof ProjectionAnnotation;
- }
-
- @Override
- public void paint(Annotation annotation, GC gc, Canvas canvas, Rectangle
rectangle) {
- if (!(annotation instanceof ProjectionAnnotation)) return;
- ProjectionAnnotation pa = (ProjectionAnnotation) annotation;
- boolean collapsed = pa.isCollapsed();
- int x = rectangle.x;
- int y = rectangle.y;
- int w = rectangle.width;
- int lineHeight = 14;
- int h = Math.min(rectangle.height, lineHeight);
- if (w < 5 || h < 5) return;
-
-
gc.setForeground(canvas.getDisplay().getSystemColor(SWT.COLOR_WIDGET_FOREGROUND));
- gc.setAntialias(SWT.ON);
- gc.setLineWidth(1);
- int pad = 2;
- int cx = x + w / 2;
- int cy = y + h / 2;
- if (collapsed) {
- // Right-pointing chevron: >
- int left = x + pad;
- int right = x + w - pad;
- int top = y + pad;
- int bottom = y + h - pad;
- gc.drawLine(left, top, right, cy);
- gc.drawLine(right, cy, left, bottom);
- } else {
- // Down-pointing chevron: v
- int left = x + pad;
- int right = x + w - pad;
- int top = y + pad;
- int bottom = y + h - pad;
- gc.drawLine(left, top, cx, bottom);
- gc.drawLine(cx, bottom, right, top);
- }
- }
- }
-
- private static final class SwtContentEditorWidget implements
IContentEditorWidget {
-
- private final Composite control;
- private final SourceViewer sourceViewer;
- private final List<ModifyListener> modifyListeners = new
CopyOnWriteArrayList<>();
- private volatile boolean suppressModify;
- private volatile String languageId;
-
- SwtContentEditorWidget(Composite control, SourceViewer sourceViewer,
String languageId) {
- this.control = control;
- this.sourceViewer = sourceViewer;
- this.languageId = languageId != null ? languageId : "";
- sourceViewer
- .getDocument()
- .addDocumentListener(
- new IDocumentListener() {
- @Override
- public void documentAboutToBeChanged(DocumentEvent event) {}
-
- @Override
- public void documentChanged(DocumentEvent event) {
- fireModify();
- }
-
- private void fireModify() {
- if (suppressModify) {
- return;
- }
- Display display = control.getDisplay();
- if (display == null || control.isDisposed()) {
- return;
- }
- display.asyncExec(
- () -> {
- if (control.isDisposed() || suppressModify) {
- return;
- }
- for (ModifyListener listener : new
ArrayList<>(modifyListeners)) {
- try {
- listener.modifyText(null);
- } catch (Exception ignored) {
- // ignore
- }
- }
- });
- }
- });
- }
-
- @Override
- public Control getControl() {
- return control;
- }
-
- @Override
- public String getText() {
- IDocument doc = sourceViewer.getDocument();
- return doc != null ? doc.get() : "";
- }
-
- @Override
- public void setText(String text) {
- doSetText(text != null ? text : "", false);
- }
-
- @Override
- public void setTextSuppressModify(String text) {
- doSetText(text != null ? text : "", true);
- }
-
- private void doSetText(String text, boolean suppress) {
- if (suppress) {
- suppressModify = true;
- }
- try {
- IDocument doc = sourceViewer.getDocument();
- if (doc != null) {
- doc.set(text);
- }
- sourceViewer.setSelectedRange(0, 0);
- sourceViewer.invalidateTextPresentation();
- } finally {
- if (suppress) {
- suppressModify = false;
- }
- }
- }
-
- @Override
- public void setLanguage(String languageId) {
- this.languageId = languageId != null ? languageId : "";
- SourceViewerConfiguration config =
RuleBasedSourceViewerConfiguration.create(languageId);
- if (config != null) {
- sourceViewer.unconfigure();
- sourceViewer.configure(config);
- sourceViewer.invalidateTextPresentation();
- }
- updateFoldingRegions();
- }
-
- @Override
- public void setReadOnly(boolean readOnly) {
- sourceViewer.setEditable(!readOnly);
- }
-
- @Override
- public void addModifyListener(ModifyListener listener) {
- if (listener != null) {
- modifyListeners.add(listener);
- }
- }
-
- @Override
- public void removeModifyListener(ModifyListener listener) {
- if (listener != null) {
- modifyListeners.remove(listener);
- }
- }
-
- @Override
- public void selectAll() {
- IDocument doc = sourceViewer.getDocument();
- if (doc != null) {
- sourceViewer.setSelectedRange(0, doc.getLength());
- }
- }
-
- @Override
- public void unselectAll() {
- sourceViewer.setSelectedRange(0, 0);
- }
-
- @Override
- public void copy() {
- sourceViewer.doOperation(SourceViewer.COPY);
- }
-
- private static final char[] OPEN_BRACKETS = {'(', '[', '{'};
- private static final char[] CLOSE_BRACKETS = {')', ']', '}'};
- private int[] highlightedPositions;
- private List<ProjectionAnnotation> currentFoldAnnotations = new
ArrayList<>();
- private Runnable pendingFoldUpdate;
-
- void installBracketHighlighting() {
- StyledText st = sourceViewer.getTextWidget();
- Color hlBg =
- new Color(
- st.getDisplay(),
- PropsUi.getInstance().isDarkMode()
- ? new org.eclipse.swt.graphics.RGB(80, 80, 80)
- : new org.eclipse.swt.graphics.RGB(220, 220, 220));
- st.addDisposeListener(e -> hlBg.dispose());
-
- CaretListener caretListener =
- event -> {
- clearBracketHighlight(st);
- String text = st.getText();
- int offset = event.caretOffset;
- int[] enclosing = findEnclosingBrackets(text, offset);
- if (enclosing != null) {
- applyBracketHighlight(st, enclosing[0], enclosing[1], hlBg);
- }
- };
- st.addCaretListener(caretListener);
- }
-
- private void clearBracketHighlight(StyledText st) {
- if (highlightedPositions == null) return;
- for (int pos : highlightedPositions) {
- if (pos < 0 || pos >= st.getCharCount()) continue;
- StyleRange existing = st.getStyleRangeAtOffset(pos);
- if (existing != null) {
- existing.background = null;
- st.setStyleRange(existing);
- }
- }
- highlightedPositions = null;
- }
-
- private void applyBracketHighlight(StyledText st, int pos1, int pos2,
Color bg) {
- highlightedPositions = new int[] {pos1, pos2};
- for (int pos : highlightedPositions) {
- if (pos < 0 || pos >= st.getCharCount()) continue;
- StyleRange sr = st.getStyleRangeAtOffset(pos);
- if (sr == null) {
- sr = new StyleRange();
- sr.start = pos;
- sr.length = 1;
- } else {
- sr = (StyleRange) sr.clone();
- }
- sr.background = bg;
- st.setStyleRange(sr);
- }
- }
-
- /**
- * Finds the innermost enclosing bracket pair around the given offset.
Scans forward from the
- * start of the text, tracking a bracket stack. The top of the stack at
the cursor position is
- * the enclosing open bracket; its forward match gives the close bracket.
- *
- * @return int[]{openPos, closePos} or null if not inside any brackets
- */
- private static int[] findEnclosingBrackets(String text, int offset) {
- Deque<int[]> stack = new ArrayDeque<>();
- boolean inStr = false;
-
- for (int i = 0; i < offset && i < text.length(); i++) {
- char c = text.charAt(i);
- if (inStr) {
- if (c == '\\' && i + 1 < text.length()) {
- i++;
- continue;
- }
- if (c == '"') inStr = false;
- continue;
- }
- if (c == '"') {
- inStr = true;
- continue;
- }
-
- int openIdx = indexOf(OPEN_BRACKETS, c);
- if (openIdx >= 0) {
- stack.push(new int[] {i, openIdx});
- } else {
- int closeIdx = indexOf(CLOSE_BRACKETS, c);
- if (closeIdx >= 0 && !stack.isEmpty() && stack.peek()[1] ==
closeIdx) {
- stack.pop();
- }
- }
- }
-
- // Walk the stack from innermost (top) outward, find one whose close
bracket is past offset
- while (!stack.isEmpty()) {
- int[] top = stack.pop();
- int openPos = top[0];
- int closePos =
- findMatchForward(text, openPos, OPEN_BRACKETS[top[1]],
CLOSE_BRACKETS[top[1]]);
- if (closePos >= offset) {
- return new int[] {openPos, closePos};
- }
- }
- return null;
- }
-
- /** Finds the matching close bracket for an open bracket at pos, scanning
forward. */
- private static int findMatchForward(String text, int pos, char open, char
close) {
- int depth = 0;
- boolean inStr = false;
- for (int i = pos; i < text.length(); i++) {
- char c = text.charAt(i);
- if (inStr) {
- if (c == '\\' && i + 1 < text.length()) {
- i++;
- continue;
- }
- if (c == '"') inStr = false;
- continue;
- }
- if (c == '"') {
- inStr = true;
- continue;
- }
- if (c == open) depth++;
- else if (c == close) depth--;
- if (depth == 0) return i;
- }
- return -1;
- }
-
- private static int indexOf(char[] arr, char c) {
- for (int i = 0; i < arr.length; i++) {
- if (arr[i] == c) return i;
- }
- return -1;
- }
-
- void installFolding() {
- if (!(sourceViewer instanceof ProjectionViewer)) return;
-
- sourceViewer
- .getDocument()
- .addDocumentListener(
- new IDocumentListener() {
- @Override
- public void documentAboutToBeChanged(DocumentEvent event) {}
-
- @Override
- public void documentChanged(DocumentEvent event) {
- scheduleFoldUpdate();
- }
- });
- }
-
- /** Paints vertical block guide lines (indent/bracket scope) like VS
Code/IntelliJ. */
- void installBlockGuidePainter() {
- StyledText st = sourceViewer.getTextWidget();
- boolean dark = PropsUi.getInstance().isDarkMode();
- org.eclipse.swt.graphics.Color guideColor =
- new org.eclipse.swt.graphics.Color(
- st.getDisplay(), dark ? 60 : 200, dark ? 60 : 200, dark ? 60 :
200);
- st.addDisposeListener(e -> guideColor.dispose());
-
- final int guideWidthPx = 10;
- final boolean useModelMapping = sourceViewer instanceof
ITextViewerExtension5;
-
- st.addPaintListener(
- new PaintListener() {
- @Override
- public void paintControl(PaintEvent e) {
- if (!"json".equalsIgnoreCase(languageId) &&
!"xml".equalsIgnoreCase(languageId)) {
- return;
- }
- String text = getText();
- if (text.isEmpty()) return;
-
- List<Position> regions =
- "json".equalsIgnoreCase(languageId)
- ? computeJsonFoldRegions(text)
- : computeXmlFoldRegions(text);
- if (regions.isEmpty()) return;
-
- // Convert to (startLine+1, closingLine) blocks — guides show
between open/close,
- // not on the opening or closing line itself.
- // Column = nesting depth (how many other blocks contain this
one).
- List<int[]> rawBlocks = new ArrayList<>();
- for (Position pos : regions) {
- int startLine = lineOfOffset(text, pos.offset);
- int closingLine = lineOfOffset(text, pos.offset + pos.length -
1);
- if (closingLine > startLine + 1) {
- rawBlocks.add(new int[] {startLine + 1, closingLine});
- }
- }
- // Deduplicate identical ranges
- Map<Long, int[]> uniqueBlocks = new HashMap<>();
- for (int[] b : rawBlocks) {
- long key = ((long) b[0] << 32) | (b[1] & 0xFFFFFFFFL);
- uniqueBlocks.putIfAbsent(key, b);
- }
- // Compute depth for each block: count how many other blocks
strictly contain it
- List<int[]> blocks = new ArrayList<>();
- for (int[] b : uniqueBlocks.values()) {
- int depth = 0;
- for (int[] other : uniqueBlocks.values()) {
- if (other[0] <= b[0] && other[1] >= b[1] && other != b) {
- depth++;
- }
- }
- blocks.add(new int[] {b[0], b[1], depth});
- }
-
- int lineHeight = st.getLineHeight();
- int topIndex = st.getTopIndex();
- int visibleCount =
- Math.min(
- st.getLineCount() - topIndex,
- (st.getClientArea().height + lineHeight - 1) /
lineHeight);
-
- e.gc.setForeground(guideColor);
- e.gc.setLineWidth(1);
- e.gc.setLineStyle(SWT.LINE_SOLID);
-
- ITextViewerExtension5 ext5 =
- useModelMapping ? (ITextViewerExtension5) sourceViewer :
null;
-
- for (int i = 0; i < visibleCount; i++) {
- int widgetLine = topIndex + i;
- int modelLine = (ext5 != null) ?
ext5.widgetLine2ModelLine(widgetLine) : widgetLine;
- if (modelLine < 0) continue;
- int y = i * lineHeight;
- for (int[] block : blocks) {
- int start = block[0];
- int endExcl = block[1];
- int col = block[2];
- if (modelLine >= start && modelLine < endExcl) {
- int x = 2 + col * guideWidthPx;
- e.gc.drawLine(x, y, x, y + lineHeight);
- }
- }
- }
- }
- });
- }
-
- /** 0-based line number for the given offset (counts '\n' before offset).
*/
- private static int lineOfOffset(String text, int offset) {
- int line = 0;
- for (int i = 0; i < offset && i < text.length(); i++) {
- if (text.charAt(i) == '\n') line++;
- }
- return line;
- }
-
- /** Paints "..." at the end of the first line of each collapsed fold (like
VS Code). */
- void installCollapsePlaceholderPainter() {
- if (!(sourceViewer instanceof ProjectionViewer)) return;
- if (!(sourceViewer instanceof ITextViewerExtension5)) return;
-
- StyledText st = sourceViewer.getTextWidget();
- boolean dark = PropsUi.getInstance().isDarkMode();
- org.eclipse.swt.graphics.Color placeholderColor =
- new org.eclipse.swt.graphics.Color(
- st.getDisplay(), dark ? 140 : 120, dark ? 140 : 120, dark ? 140
: 120);
- st.addDisposeListener(e -> placeholderColor.dispose());
-
- st.addPaintListener(
- e1 -> {
- ProjectionViewer pv = (ProjectionViewer) sourceViewer;
- ProjectionAnnotationModel model =
pv.getProjectionAnnotationModel();
- if (model == null) return;
- IDocument doc = sourceViewer.getDocument();
- if (doc == null) return;
- ITextViewerExtension5 ext5 = (ITextViewerExtension5) sourceViewer;
-
- e1.gc.setForeground(placeholderColor);
- e1.gc.setFont(st.getFont());
-
- java.util.Iterator<Annotation> it = model.getAnnotationIterator();
- while (it.hasNext()) {
- Annotation ann = it.next();
- if (!(ann instanceof ProjectionAnnotation)) continue;
- if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
- Position pos = model.getPosition(ann);
- if (pos == null) continue;
- int modelLine;
- try {
- modelLine = doc.getLineOfOffset(pos.offset);
- } catch (org.eclipse.jface.text.BadLocationException ex) {
- continue;
- }
- int widgetLine = ext5.modelLine2WidgetLine(modelLine);
- if (widgetLine < 0 || widgetLine >= st.getLineCount()) continue;
- int lineEndOffset;
- try {
- int lineStart = st.getOffsetAtLine(widgetLine);
- int lineLen = st.getLine(widgetLine).length();
- lineEndOffset = lineStart + lineLen;
- } catch (Exception ex) {
- continue;
- }
- org.eclipse.swt.graphics.Point pt =
st.getLocationAtOffset(lineEndOffset);
- if (pt != null) {
- e1.gc.drawText("...", pt.x + 4, pt.y);
- }
- }
- });
-
- // Click on "..." placeholder expands the collapsed fold
- final int placeholderClickWidth = 24;
- st.addMouseListener(
- new org.eclipse.swt.events.MouseAdapter() {
- @Override
- public void mouseUp(org.eclipse.swt.events.MouseEvent e) {
- if (e.button != 1) return;
- ProjectionViewer pv = (ProjectionViewer) sourceViewer;
- ProjectionAnnotationModel model =
pv.getProjectionAnnotationModel();
- if (model == null) return;
- IDocument doc = sourceViewer.getDocument();
- if (doc == null) return;
- ITextViewerExtension5 ext5 = (ITextViewerExtension5)
sourceViewer;
-
- int lineHeight = st.getLineHeight();
- java.util.Iterator<Annotation> it =
model.getAnnotationIterator();
- while (it.hasNext()) {
- Annotation ann = it.next();
- if (!(ann instanceof ProjectionAnnotation)) continue;
- if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
- Position pos = model.getPosition(ann);
- if (pos == null) continue;
- int modelLine;
- try {
- modelLine = doc.getLineOfOffset(pos.offset);
- } catch (org.eclipse.jface.text.BadLocationException ex) {
- continue;
- }
- int widgetLine = ext5.modelLine2WidgetLine(modelLine);
- if (widgetLine < 0 || widgetLine >= st.getLineCount())
continue;
- int lineEndOffset;
- try {
- int lineStart = st.getOffsetAtLine(widgetLine);
- int lineLen = st.getLine(widgetLine).length();
- lineEndOffset = lineStart + lineLen;
- } catch (Exception ex) {
- continue;
- }
- org.eclipse.swt.graphics.Point pt =
st.getLocationAtOffset(lineEndOffset);
- if (pt == null) continue;
- int x1 = pt.x + 4;
- int y1 = pt.y;
- if (e.x >= x1
- && e.x <= x1 + placeholderClickWidth
- && e.y >= y1
- && e.y < y1 + lineHeight) {
- model.expand(ann);
- return;
- }
- }
- }
- });
- }
-
- private void scheduleFoldUpdate() {
- Display display = control.getDisplay();
- if (display == null || display.isDisposed()) return;
- if (pendingFoldUpdate != null) {
- display.timerExec(-1, pendingFoldUpdate);
- }
- pendingFoldUpdate =
- () -> {
- if (!control.isDisposed()) {
- updateFoldingRegions();
- }
- pendingFoldUpdate = null;
- };
- display.timerExec(500, pendingFoldUpdate);
- }
-
- private void updateFoldingRegions() {
- if (!(sourceViewer instanceof ProjectionViewer)) return;
- ProjectionViewer pv = (ProjectionViewer) sourceViewer;
- ProjectionAnnotationModel model = pv.getProjectionAnnotationModel();
- if (model == null) return;
-
- if (pendingFoldUpdate != null && !control.isDisposed()) {
- control.getDisplay().timerExec(-1, pendingFoldUpdate);
- pendingFoldUpdate = null;
- }
-
- String text = getText();
- List<Position> regions;
- if ("json".equalsIgnoreCase(languageId)) {
- regions = computeJsonFoldRegions(text);
- } else if ("xml".equalsIgnoreCase(languageId)) {
- regions = computeXmlFoldRegions(text);
- } else {
- regions = List.of();
- }
-
- Annotation[] deletions = currentFoldAnnotations.toArray(new
Annotation[0]);
- Map<ProjectionAnnotation, Position> additions = new HashMap<>();
- List<ProjectionAnnotation> newAnnotations = new ArrayList<>();
- for (Position pos : regions) {
- ProjectionAnnotation annotation = new ProjectionAnnotation();
- additions.put(annotation, pos);
- newAnnotations.add(annotation);
- }
-
- model.modifyAnnotations(deletions, additions, null);
- currentFoldAnnotations = newAnnotations;
- }
-
- private static List<Position> computeJsonFoldRegions(String text) {
- List<Position> regions = new ArrayList<>();
- Deque<Integer> stack = new ArrayDeque<>();
- boolean inString = false;
-
- for (int i = 0; i < text.length(); i++) {
- char c = text.charAt(i);
- if (inString) {
- if (c == '\\' && i + 1 < text.length()) {
- i++;
- continue;
- }
- if (c == '"') inString = false;
- continue;
- }
- if (c == '"') {
- inString = true;
- continue;
- }
- if (c == '{' || c == '[') {
- stack.push(i);
- } else if (c == '}' || c == ']') {
- if (!stack.isEmpty()) {
- int start = stack.pop();
- if (spanMultipleLines(text, start, i)) {
- regions.add(new Position(start, i - start + 1));
- }
- }
- }
- }
- return regions;
- }
-
- private static List<Position> computeXmlFoldRegions(String text) {
- List<Position> regions = new ArrayList<>();
-
- // Fold multi-line comments
- int idx = 0;
- while (idx < text.length()) {
- int cs = text.indexOf("<!--", idx);
- if (cs < 0) break;
- int ce = text.indexOf("-->", cs + 4);
- if (ce < 0) break;
- ce += 3;
- if (spanMultipleLines(text, cs, ce)) {
- regions.add(new Position(cs, ce - cs));
- }
- idx = ce;
- }
-
- // Fold multi-line CDATA
- idx = 0;
- while (idx < text.length()) {
- int cs = text.indexOf("<![CDATA[", idx);
- if (cs < 0) break;
- int ce = text.indexOf("]]>", cs + 9);
- if (ce < 0) break;
- ce += 3;
- if (spanMultipleLines(text, cs, ce)) {
- regions.add(new Position(cs, ce - cs));
- }
- idx = ce;
- }
-
- // Fold matching open/close tags
- Deque<String> nameStack = new ArrayDeque<>();
- Deque<Integer> posStack = new ArrayDeque<>();
- int i = 0;
- while (i < text.length()) {
- if (text.charAt(i) != '<') {
- i++;
- continue;
- }
- if (text.startsWith("<!--", i) || text.startsWith("<![CDATA[", i)) {
- int skip =
- text.startsWith("<!--", i) ? text.indexOf("-->", i + 4) :
text.indexOf("]]>", i + 9);
- i = (skip < 0) ? text.length() : skip + 3;
- continue;
- }
- if (text.startsWith("<?", i) || text.startsWith("<!", i)) {
- int gt = text.indexOf('>', i);
- i = (gt < 0) ? text.length() : gt + 1;
- continue;
- }
-
- // Close tag
- if (text.startsWith("</", i)) {
- int gt = text.indexOf('>', i);
- if (gt < 0) break;
- String closeName = text.substring(i + 2, gt).trim();
- Deque<String> tmpNames = new ArrayDeque<>();
- Deque<Integer> tmpPos = new ArrayDeque<>();
- boolean found = false;
- while (!nameStack.isEmpty()) {
- String n = nameStack.pop();
- int p = posStack.pop();
- if (n.equals(closeName)) {
- int end = gt + 1;
- if (spanMultipleLines(text, p, end)) {
- regions.add(new Position(p, end - p));
- }
- found = true;
- break;
- }
- tmpNames.push(n);
- tmpPos.push(p);
- }
- if (!found) {
- while (!tmpNames.isEmpty()) {
- nameStack.push(tmpNames.pop());
- posStack.push(tmpPos.pop());
- }
- }
- i = gt + 1;
- continue;
- }
-
- // Open tag
- int gt = text.indexOf('>', i);
- if (gt < 0) break;
- boolean selfClosing = text.charAt(gt - 1) == '/';
- if (!selfClosing) {
- int nameStart = i + 1;
- int nameEnd = nameStart;
- while (nameEnd < text.length()) {
- char ch = text.charAt(nameEnd);
- if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch ==
'>' || ch == '/')
- break;
- nameEnd++;
- }
- String tagName = text.substring(nameStart, nameEnd);
- if (!tagName.isEmpty()) {
- nameStack.push(tagName);
- posStack.push(i);
- }
- }
- i = gt + 1;
- }
-
- return regions;
- }
-
- private static boolean spanMultipleLines(String text, int start, int end) {
- for (int i = start; i < end && i < text.length(); i++) {
- if (text.charAt(i) == '\n') return true;
- }
- return false;
- }
-
- /**
- * When language is XML, typing '>' after an open tag inserts the closing
tag and places the
- * caret between '>' and '</tagname>'.
- */
- void installXmlAutoClose() {
- if (!(sourceViewer instanceof
org.eclipse.jface.text.ITextViewerExtension)) {
- return;
- }
- ((org.eclipse.jface.text.ITextViewerExtension) sourceViewer)
- .appendVerifyKeyListener(
- new VerifyKeyListener() {
- @Override
- public void verifyKey(VerifyEvent event) {
- if (event.character != '>' ||
!"xml".equalsIgnoreCase(languageId)) {
- return;
- }
- IDocument doc = sourceViewer.getDocument();
- if (doc == null) return;
- int offset = sourceViewer.getSelectedRange().x;
- String before = "";
- try {
- before = offset > 0 ? doc.get(0, offset) : "";
- } catch (org.eclipse.jface.text.BadLocationException e) {
- return;
- }
- String tagName = findOpenTagNameBefore(before);
- if (tagName == null) return;
- event.doit = false;
- try {
- doc.replace(offset, 0, "></" + tagName + ">");
- sourceViewer.setSelectedRange(offset + 1, 0);
- } catch (org.eclipse.jface.text.BadLocationException e) {
- // ignore
- }
- }
-
- private String findOpenTagNameBefore(String text) {
- int i = text.length() - 1;
- while (i >= 0 && text.charAt(i) != '<') i--;
- if (i < 0) return null;
- if (i + 1 < text.length()) {
- char next = text.charAt(i + 1);
- if (next == '/' || next == '?' || next == '!') return null;
- }
- StringBuilder name = new StringBuilder();
- for (i = i + 1; i < text.length(); i++) {
- char ch = text.charAt(i);
- if (ch == ' ' || ch == '\t' || ch == '>' || ch == '/')
break;
- if (Character.isLetterOrDigit(ch)
- || ch == ':'
- || ch == '-'
- || ch == '_'
- || ch == '.') {
- name.append(ch);
- } else {
- break;
- }
- }
- return name.length() > 0 ? name.toString() : null;
- }
- });
- }
+ return new ContentEditorWidget(parent, languageId);
}
}
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
new file mode 100644
index 0000000000..4e22e4cb73
--- /dev/null
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
@@ -0,0 +1,1213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with the
License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.apache.hop.core.Props;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+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.editor.IContentEditorWidget;
+import org.eclipse.jface.text.DocumentEvent;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.IDocumentExtension3;
+import org.eclipse.jface.text.IDocumentListener;
+import org.eclipse.jface.text.ITextOperationTarget;
+import org.eclipse.jface.text.ITextViewerExtension5;
+import org.eclipse.jface.text.Position;
+import org.eclipse.jface.text.rules.FastPartitioner;
+import org.eclipse.jface.text.rules.RuleBasedPartitionScanner;
+import org.eclipse.jface.text.rules.Token;
+import org.eclipse.jface.text.source.Annotation;
+import org.eclipse.jface.text.source.AnnotationModel;
+import org.eclipse.jface.text.source.AnnotationRulerColumn;
+import org.eclipse.jface.text.source.CompositeRuler;
+import org.eclipse.jface.text.source.IAnnotationAccess;
+import org.eclipse.jface.text.source.IAnnotationAccessExtension;
+import org.eclipse.jface.text.source.LineNumberRulerColumn;
+import org.eclipse.jface.text.source.SourceViewer;
+import org.eclipse.jface.text.source.SourceViewerConfiguration;
+import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
+import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel;
+import org.eclipse.jface.text.source.projection.ProjectionViewer;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CaretListener;
+import org.eclipse.swt.custom.StyleRange;
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.custom.VerifyKeyListener;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.MouseAdapter;
+import org.eclipse.swt.events.MouseEvent;
+import org.eclipse.swt.events.PaintEvent;
+import org.eclipse.swt.events.PaintListener;
+import org.eclipse.swt.events.VerifyEvent;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.GC;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Canvas;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Menu;
+
+/**
+ * Desktop (RCP) implementation of the content editor using Eclipse JFace
SourceViewer with
+ * rule-based syntax highlighting, bracket matching, and XML auto-closing tags.
+ *
+ * <p>Toolbar button registration lives in {@link ContentEditorActions}
(shared with Hop Web).
+ * Context menu items and keyboard shortcuts stay here (RCP / JFace only).
+ */
+@GuiPlugin(name = "Content editor (desktop)")
+public class ContentEditorWidget implements IContentEditorWidget {
+ private static final Class<?> PKG = ContentEditorWidget.class;
+
+ public static final String ID_CONTEXT_MENU_UNDO =
"ContentEditor-ContextMenu-10000-undo";
+ public static final String ID_CONTEXT_MENU_REDO =
"ContentEditor-ContextMenu-10010-redo";
+ public static final String ID_CONTEXT_MENU_SELECT_ALL =
+ "ContentEditor-ContextMenu-20000-select-all";
+ public static final String ID_CONTEXT_MENU_UNSELECT_ALL =
+ "ContentEditor-ContextMenu-20010-unselect-all";
+ public static final String ID_CONTEXT_MENU_COPY =
"ContentEditor-ContextMenu-30000-copy";
+ public static final String ID_CONTEXT_MENU_PASTE =
"ContentEditor-ContextMenu-30010-paste";
+ public static final String ID_CONTEXT_MENU_CUT =
"ContentEditor-ContextMenu-30020-cut";
+
+ private static final char[] OPEN_BRACKETS = {'(', '[', '{'};
+ private static final char[] CLOSE_BRACKETS = {')', ']', '}'};
+
+ private final Composite control;
+ private ProjectionViewer sourceViewer;
+ private final List<ModifyListener> modifyListeners = new
CopyOnWriteArrayList<>();
+ private volatile boolean suppressModify;
+ private volatile String languageId;
+ private Control toolbar;
+ private GuiToolbarWidgets toolbarWidgets;
+ private GuiMenuWidgets contextMenuWidgets;
+ private int[] highlightedPositions;
+ private List<ProjectionAnnotation> currentFoldAnnotations = new
ArrayList<>();
+ private Runnable pendingFoldUpdate;
+
+ ContentEditorWidget(Composite parent, String languageId) {
+
+ this.languageId = languageId != null ? languageId : "";
+
+ control = new Composite(parent, SWT.NONE);
+ control.setLayout(new FormLayout());
+ control.setLayoutData(FormDataBuilder.builder().fullSize().build());
+ PropsUi.setLook(parent);
+
+ addToolbar();
+ addSourceViewer();
+ }
+
+ private void addSourceViewer() {
+ int styles = SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL | SWT.BORDER;
+ CompositeRuler ruler = new CompositeRuler();
+ this.sourceViewer = new ProjectionViewer(control, ruler, null, false,
styles);
+ sourceViewer.setEditable(true);
+ org.eclipse.jface.text.Document document = new
org.eclipse.jface.text.Document();
+ RuleBasedPartitionScanner partitionScanner = new
RuleBasedPartitionScanner();
+ partitionScanner.setDefaultReturnToken(new
Token(IDocument.DEFAULT_CONTENT_TYPE));
+ FastPartitioner partitioner = new FastPartitioner(partitionScanner, new
String[0]);
+ partitioner.connect(document);
+ ((IDocumentExtension3) document)
+ .setDocumentPartitioner(IDocumentExtension3.DEFAULT_PARTITIONING,
partitioner);
+ sourceViewer.setDocument(document, new AnnotationModel());
+
+ SourceViewerConfiguration config =
RuleBasedSourceViewerConfiguration.create(languageId);
+ if (config != null) {
+ sourceViewer.configure(config);
+ }
+ sourceViewer.invalidateTextPresentation();
+
+ // Enable projection (folding) without ProjectionSupport so we control the
ruler column
+ sourceViewer.doOperation(ProjectionViewer.TOGGLE);
+
+ // Line numbers first (index 0), then fold column (index 1) with explicit
width
+ LineNumberRulerColumn lineNumberColumn = new LineNumberRulerColumn();
+ ruler.addDecorator(0, lineNumberColumn);
+
+ ProjectionAnnotationModel projModel =
sourceViewer.getProjectionAnnotationModel();
+ if (projModel != null) {
+ FoldingAnnotationAccess foldAccess = new FoldingAnnotationAccess();
+ AnnotationRulerColumn foldColumn = new AnnotationRulerColumn(projModel,
20, foldAccess);
+ foldColumn.addAnnotationType(ProjectionAnnotation.TYPE);
+ ruler.addDecorator(1, foldColumn);
+
+ Control foldControl = foldColumn.getControl();
+ if (foldControl != null) {
+ foldControl.addMouseListener(
+ new MouseAdapter() {
+ @Override
+ public void mouseUp(MouseEvent e) {
+ int line = foldColumn.toDocumentLineNumber(e.y);
+ if (line < 0) return;
+ IDocument doc = sourceViewer.getDocument();
+ if (doc == null) return;
+ java.util.Iterator<Annotation> it =
projModel.getAnnotationIterator();
+ while (it.hasNext()) {
+ Annotation ann = it.next();
+ if (ann instanceof ProjectionAnnotation) {
+ Position pos = projModel.getPosition(ann);
+ if (pos != null) {
+ try {
+ int annLine = doc.getLineOfOffset(pos.getOffset());
+ if (annLine == line) {
+
projModel.toggleExpansionState((ProjectionAnnotation) ann);
+ return;
+ }
+ } catch (org.eclipse.jface.text.BadLocationException
ignored) {
+ // ignore
+ }
+ }
+ }
+ }
+ }
+ });
+ }
+ }
+
+ applyFontFromHop(sourceViewer);
+ org.eclipse.swt.graphics.Font editorFont =
sourceViewer.getTextWidget().getFont();
+ if (editorFont != null && !editorFont.isDisposed()) {
+ lineNumberColumn.setFont(editorFont);
+ }
+ boolean dark = PropsUi.getInstance().isDarkMode();
+ if (dark) {
+ org.eclipse.swt.graphics.Color lineNumFg =
+ new org.eclipse.swt.graphics.Color(control.getDisplay(), 130, 130,
130);
+ org.eclipse.swt.graphics.Color lineNumBg =
+ new org.eclipse.swt.graphics.Color(control.getDisplay(), 40, 40, 40);
+ lineNumberColumn.setForeground(lineNumFg);
+ lineNumberColumn.setBackground(lineNumBg);
+ } else {
+ org.eclipse.swt.graphics.Color lineNumFg =
+ new org.eclipse.swt.graphics.Color(control.getDisplay(), 120, 120,
120);
+ lineNumberColumn.setForeground(lineNumFg);
+ }
+ sourceViewer.getTextWidget().setTabs(4);
+ sourceViewer
+ .getDocument()
+ .addDocumentListener(
+ new IDocumentListener() {
+ @Override
+ public void documentAboutToBeChanged(DocumentEvent event) {}
+
+ @Override
+ public void documentChanged(DocumentEvent event) {
+ fireModify();
+ }
+
+ private void fireModify() {
+ if (suppressModify) {
+ return;
+ }
+ Display display = control.getDisplay();
+ if (display == null || control.isDisposed()) {
+ return;
+ }
+ display.asyncExec(
+ () -> {
+ if (control.isDisposed() || suppressModify) {
+ return;
+ }
+
+ updateGui();
+
+ for (ModifyListener listener : new
ArrayList<>(modifyListeners)) {
+ try {
+ listener.modifyText(null);
+ } catch (Exception ignored) {
+ // ignore
+ }
+ }
+ });
+ }
+ });
+
+ sourceViewer.addSelectionChangedListener(
+ event -> {
+ if (suppressModify) {
+ return;
+ }
+ updateGui();
+ });
+
+ sourceViewer
+ .getControl()
+
.setLayoutData(FormDataBuilder.builder().top(toolbar).bottom().fullWidth().build());
+
+ // Installs bracket highlighting and XML auto-closing when language is xml.
+ installBracketHighlighting();
+ installFolding();
+ installBlockGuidePainter();
+ installCollapsePlaceholderPainter();
+ installXmlAutoClose();
+ installMenu();
+
+ updateGui();
+ }
+
+ public void updateGui() {
+ boolean canUndo = sourceViewer.canDoOperation(ITextOperationTarget.UNDO);
+ boolean canRedo = sourceViewer.canDoOperation(ITextOperationTarget.REDO);
+ boolean canCut = sourceViewer.canDoOperation(ITextOperationTarget.CUT);
+ boolean canCopy = sourceViewer.canDoOperation(ITextOperationTarget.COPY);
+ boolean canPaste = sourceViewer.canDoOperation(ITextOperationTarget.PASTE);
+
+ // Update the toolbar items...
+ if (toolbarWidgets != null) {
+ toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_UNDO,
canUndo);
+ toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_REDO,
canRedo);
+ toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_CUT,
canCut);
+ toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_COPY,
canCopy);
+ toolbarWidgets.enableToolbarItem(ContentEditorActions.ID_TOOLBAR_PASTE,
canPaste);
+ }
+
+ // Update the HopGui main menu items...
+ GuiMenuWidgets mainMenuWidgets = HopGui.getInstance().getMainMenuWidgets();
+ mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_UNDO, canUndo);
+ mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_REDO, canRedo);
+ mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_CUT, canCut);
+ mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_COPY, canCopy);
+ mainMenuWidgets.enableMenuItem(HopGui.ID_MAIN_MENU_EDIT_PASTE, canPaste);
+ }
+
+ private void addToolbar() {
+ // Create the toolbar container using the facade
+ IToolbarContainer toolbarContainer =
+ ToolbarFacade.createToolbarContainer(control, SWT.WRAP | SWT.RIGHT |
SWT.HORIZONTAL);
+ toolbar = toolbarContainer.getControl();
+ toolbar.setLayoutData(FormDataBuilder.builder().top().fullWidth().build());
+ PropsUi.setLook(toolbar, Props.WIDGET_STYLE_TOOLBAR);
+
+ // Create the toolbar widgets using GuiToolbarWidgets
+ toolbarWidgets = new GuiToolbarWidgets();
+ toolbarWidgets.registerGuiPluginObject(this);
+ toolbarWidgets.createToolbarWidgets(toolbarContainer,
GUI_PLUGIN_TOOLBAR_PARENT_ID);
+ toolbar.pack();
+ }
+
+ @Override
+ public Control getControl() {
+ return control;
+ }
+
+ @Override
+ public String getText() {
+ IDocument doc = sourceViewer.getDocument();
+ return doc != null ? doc.get() : "";
+ }
+
+ @Override
+ public void setText(String text) {
+ doSetText(text != null ? text : "", false);
+ }
+
+ @Override
+ public void setTextSuppressModify(String text) {
+ doSetText(text != null ? text : "", true);
+ }
+
+ private void doSetText(String text, boolean suppress) {
+ if (suppress) {
+ suppressModify = true;
+ }
+ try {
+ IDocument doc = sourceViewer.getDocument();
+ if (doc != null) {
+ doc.set(text);
+ }
+ sourceViewer.setSelectedRange(0, 0);
+ sourceViewer.invalidateTextPresentation();
+ } finally {
+ if (suppress) {
+ suppressModify = false;
+ }
+ }
+ }
+
+ @Override
+ public String getLanguage() {
+ return languageId;
+ }
+
+ @Override
+ public void setLanguage(String languageId) {
+ this.languageId = languageId != null ? languageId : "";
+ SourceViewerConfiguration config =
RuleBasedSourceViewerConfiguration.create(languageId);
+ if (config != null) {
+ sourceViewer.unconfigure();
+ sourceViewer.configure(config);
+ sourceViewer.invalidateTextPresentation();
+ }
+ updateFoldingRegions();
+ }
+
+ @Override
+ public void setReadOnly(boolean readOnly) {
+ sourceViewer.setEditable(!readOnly);
+ }
+
+ @Override
+ public void addModifyListener(ModifyListener listener) {
+ if (listener != null) {
+ modifyListeners.add(listener);
+ }
+ }
+
+ @Override
+ public void removeModifyListener(ModifyListener listener) {
+ if (listener != null) {
+ modifyListeners.remove(listener);
+ }
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_UNDO,
+ label = "i18n::ContentEditorWidget.Menu.Undo",
+ image = "ui/images/undo.svg")
+ @GuiKeyboardShortcut(control = true, key = 'z')
+ @GuiOsxKeyboardShortcut(command = true, key = 'z')
+ @Override
+ public void undo() {
+ sourceViewer.doOperation(SourceViewer.UNDO);
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_REDO,
+ label = "i18n::ContentEditorWidget.Menu.Redo",
+ image = "ui/images/redo.svg")
+ @GuiKeyboardShortcut(control = true, shift = true, key = 'z')
+ @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'z')
+ @Override
+ public void redo() {
+ sourceViewer.doOperation(SourceViewer.REDO);
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_SELECT_ALL,
+ label = "i18n::ContentEditorWidget.Menu.SelectAll",
+ image = "ui/images/select-all.svg",
+ separator = true)
+ @GuiKeyboardShortcut(control = true, key = 'a')
+ @GuiOsxKeyboardShortcut(command = true, key = 'a')
+ @Override
+ public void selectAll() {
+ IDocument doc = sourceViewer.getDocument();
+ if (doc != null) {
+ sourceViewer.setSelectedRange(0, doc.getLength());
+ }
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_UNSELECT_ALL,
+ label = "i18n::ContentEditorWidget.Menu.UnselectAll",
+ image = "ui/images/unselect-all.svg")
+ @GuiKeyboardShortcut(key = SWT.ESC)
+ @GuiOsxKeyboardShortcut(key = SWT.ESC)
+ @Override
+ public void unselectAll() {
+ sourceViewer.setSelectedRange(0, 0);
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_COPY,
+ label = "i18n::ContentEditorWidget.Menu.Copy",
+ image = "ui/images/copy.svg",
+ separator = true)
+ @GuiKeyboardShortcut(control = true, key = 'c')
+ @GuiOsxKeyboardShortcut(command = true, key = 'c')
+ @Override
+ public void copy() {
+ sourceViewer.doOperation(SourceViewer.COPY);
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_CUT,
+ label = "i18n::ContentEditorWidget.Menu.Cut",
+ image = "ui/images/cut.svg")
+ @GuiKeyboardShortcut(control = true, key = 'x')
+ @GuiOsxKeyboardShortcut(command = true, key = 'x')
+ @Override
+ public void cut() {
+ sourceViewer.doOperation(SourceViewer.CUT);
+ }
+
+ @GuiMenuElement(
+ root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+ id = ID_CONTEXT_MENU_PASTE,
+ label = "i18n::ContentEditorWidget.Menu.Paste",
+ image = "ui/images/paste.svg")
+ @GuiKeyboardShortcut(control = true, key = 'v')
+ @GuiOsxKeyboardShortcut(command = true, key = 'v')
+ @Override
+ public void paste() {
+ sourceViewer.doOperation(SourceViewer.PASTE);
+ }
+
+ private static void applyFontFromHop(SourceViewer sourceViewer) {
+ try {
+ org.eclipse.swt.graphics.Font swtFont =
GuiResource.getInstance().getFontFixed();
+ if (swtFont != null && !swtFont.isDisposed()) {
+ sourceViewer.getTextWidget().setFont(swtFont);
+ }
+ } catch (Exception e) {
+ // GuiResource or font not available
+ }
+ }
+
+ void installBracketHighlighting() {
+ StyledText st = sourceViewer.getTextWidget();
+ Color hlBg =
+ new Color(
+ st.getDisplay(),
+ PropsUi.getInstance().isDarkMode()
+ ? new org.eclipse.swt.graphics.RGB(80, 80, 80)
+ : new org.eclipse.swt.graphics.RGB(220, 220, 220));
+ st.addDisposeListener(e -> hlBg.dispose());
+
+ CaretListener caretListener =
+ event -> {
+ clearBracketHighlight(st);
+ String text = st.getText();
+ int offset = event.caretOffset;
+ int[] enclosing = findEnclosingBrackets(text, offset);
+ if (enclosing != null) {
+ applyBracketHighlight(st, enclosing[0], enclosing[1], hlBg);
+ }
+ };
+ st.addCaretListener(caretListener);
+ }
+
+ private void clearBracketHighlight(StyledText st) {
+ if (highlightedPositions == null) return;
+ for (int pos : highlightedPositions) {
+ if (pos < 0 || pos >= st.getCharCount()) continue;
+ StyleRange existing = st.getStyleRangeAtOffset(pos);
+ if (existing != null) {
+ existing.background = null;
+ st.setStyleRange(existing);
+ }
+ }
+ highlightedPositions = null;
+ }
+
+ private void applyBracketHighlight(StyledText st, int pos1, int pos2, Color
bg) {
+ highlightedPositions = new int[] {pos1, pos2};
+ for (int pos : highlightedPositions) {
+ if (pos < 0 || pos >= st.getCharCount()) continue;
+ StyleRange sr = st.getStyleRangeAtOffset(pos);
+ if (sr == null) {
+ sr = new StyleRange();
+ sr.start = pos;
+ sr.length = 1;
+ } else {
+ sr = (StyleRange) sr.clone();
+ }
+ sr.background = bg;
+ st.setStyleRange(sr);
+ }
+ }
+
+ /**
+ * Finds the innermost enclosing bracket pair around the given offset. Scans
forward from the
+ * start of the text, tracking a bracket stack. The top of the stack at the
cursor position is the
+ * enclosing open bracket; its forward match gives the close bracket.
+ *
+ * @return int[]{openPos, closePos} or null if not inside any brackets
+ */
+ private static int[] findEnclosingBrackets(String text, int offset) {
+ Deque<int[]> stack = new ArrayDeque<>();
+ boolean inStr = false;
+
+ for (int i = 0; i < offset && i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inStr) {
+ if (c == '\\' && i + 1 < text.length()) {
+ i++;
+ continue;
+ }
+ if (c == '"') inStr = false;
+ continue;
+ }
+ if (c == '"') {
+ inStr = true;
+ continue;
+ }
+
+ int openIdx = indexOf(OPEN_BRACKETS, c);
+ if (openIdx >= 0) {
+ stack.push(new int[] {i, openIdx});
+ } else {
+ int closeIdx = indexOf(CLOSE_BRACKETS, c);
+ if (closeIdx >= 0 && !stack.isEmpty() && stack.peek()[1] == closeIdx) {
+ stack.pop();
+ }
+ }
+ }
+
+ // Walk the stack from innermost (top) outward, find one whose close
bracket is past offset
+ while (!stack.isEmpty()) {
+ int[] top = stack.pop();
+ int openPos = top[0];
+ int closePos = findMatchForward(text, openPos, OPEN_BRACKETS[top[1]],
CLOSE_BRACKETS[top[1]]);
+ if (closePos >= offset) {
+ return new int[] {openPos, closePos};
+ }
+ }
+ return null;
+ }
+
+ /** Finds the matching close bracket for an open bracket at pos, scanning
forward. */
+ private static int findMatchForward(String text, int pos, char open, char
close) {
+ int depth = 0;
+ boolean inStr = false;
+ for (int i = pos; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inStr) {
+ if (c == '\\' && i + 1 < text.length()) {
+ i++;
+ continue;
+ }
+ if (c == '"') inStr = false;
+ continue;
+ }
+ if (c == '"') {
+ inStr = true;
+ continue;
+ }
+ if (c == open) depth++;
+ else if (c == close) depth--;
+ if (depth == 0) return i;
+ }
+ return -1;
+ }
+
+ private static int indexOf(char[] arr, char c) {
+ for (int i = 0; i < arr.length; i++) {
+ if (arr[i] == c) return i;
+ }
+ return -1;
+ }
+
+ void installMenu() {
+ StyledText styledText = sourceViewer.getTextWidget();
+
+ Menu contextMenu = new Menu(styledText);
+ contextMenuWidgets = new GuiMenuWidgets();
+ contextMenuWidgets.registerGuiPluginObject(this);
+ contextMenuWidgets.createMenuWidgets(
+ GUI_PLUGIN_CONTEXT_MENU_PARENT_ID, styledText.getShell(), contextMenu);
+ styledText.setMenu(contextMenu);
+ styledText.addListener(
+ SWT.MenuDetect,
+ event -> {
+ // Update the context menu items...
+ contextMenuWidgets.enableMenuItem(
+ ID_CONTEXT_MENU_UNDO,
sourceViewer.canDoOperation(ITextOperationTarget.UNDO));
+ contextMenuWidgets.enableMenuItem(
+ ID_CONTEXT_MENU_REDO,
sourceViewer.canDoOperation(ITextOperationTarget.REDO));
+ contextMenuWidgets.enableMenuItem(
+ ID_CONTEXT_MENU_CUT,
sourceViewer.canDoOperation(ITextOperationTarget.CUT));
+ contextMenuWidgets.enableMenuItem(
+ ID_CONTEXT_MENU_COPY,
sourceViewer.canDoOperation(ITextOperationTarget.COPY));
+ contextMenuWidgets.enableMenuItem(
+ ID_CONTEXT_MENU_PASTE,
sourceViewer.canDoOperation(ITextOperationTarget.PASTE));
+ });
+ }
+
+ void installFolding() {
+ sourceViewer
+ .getDocument()
+ .addDocumentListener(
+ new IDocumentListener() {
+ @Override
+ public void documentAboutToBeChanged(DocumentEvent event) {}
+
+ @Override
+ public void documentChanged(DocumentEvent event) {
+ scheduleFoldUpdate();
+ }
+ });
+ }
+
+ /** Paints vertical block guide lines (indent/bracket scope) like VS
Code/IntelliJ. */
+ void installBlockGuidePainter() {
+ StyledText st = sourceViewer.getTextWidget();
+ boolean dark = PropsUi.getInstance().isDarkMode();
+ Color guideColor =
+ new Color(st.getDisplay(), dark ? 60 : 200, dark ? 60 : 200, dark ? 60
: 200);
+ st.addDisposeListener(e -> guideColor.dispose());
+
+ final int guideWidthPx = 10;
+
+ st.addPaintListener(
+ new PaintListener() {
+ @Override
+ public void paintControl(PaintEvent e) {
+ if (!"json".equalsIgnoreCase(languageId) &&
!"xml".equalsIgnoreCase(languageId)) {
+ return;
+ }
+ String text = getText();
+ if (text.isEmpty()) return;
+
+ List<Position> regions =
+ "json".equalsIgnoreCase(languageId)
+ ? computeJsonFoldRegions(text)
+ : computeXmlFoldRegions(text);
+ if (regions.isEmpty()) return;
+
+ // Convert to (startLine+1, closingLine) blocks — guides show
between open/close,
+ // not on the opening or closing line itself.
+ // Column = nesting depth (how many other blocks contain this one).
+ List<int[]> rawBlocks = new ArrayList<>();
+ for (Position pos : regions) {
+ int startLine = lineOfOffset(text, pos.offset);
+ int closingLine = lineOfOffset(text, pos.offset + pos.length -
1);
+ if (closingLine > startLine + 1) {
+ rawBlocks.add(new int[] {startLine + 1, closingLine});
+ }
+ }
+ // Deduplicate identical ranges
+ Map<Long, int[]> uniqueBlocks = new HashMap<>();
+ for (int[] b : rawBlocks) {
+ long key = ((long) b[0] << 32) | (b[1] & 0xFFFFFFFFL);
+ uniqueBlocks.putIfAbsent(key, b);
+ }
+ // Compute depth for each block: count how many other blocks
strictly contain it
+ List<int[]> blocks = new ArrayList<>();
+ for (int[] b : uniqueBlocks.values()) {
+ int depth = 0;
+ for (int[] other : uniqueBlocks.values()) {
+ if (other[0] <= b[0] && other[1] >= b[1] && other != b) {
+ depth++;
+ }
+ }
+ blocks.add(new int[] {b[0], b[1], depth});
+ }
+
+ int lineHeight = st.getLineHeight();
+ int topIndex = st.getTopIndex();
+ int visibleCount =
+ Math.min(
+ st.getLineCount() - topIndex,
+ (st.getClientArea().height + lineHeight - 1) / lineHeight);
+
+ e.gc.setForeground(guideColor);
+ e.gc.setLineWidth(1);
+ e.gc.setLineStyle(SWT.LINE_SOLID);
+
+ for (int i = 0; i < visibleCount; i++) {
+ int widgetLine = topIndex + i;
+ int modelLine = sourceViewer.widgetLine2ModelLine(widgetLine);
+ if (modelLine < 0) continue;
+ int y = i * lineHeight;
+ for (int[] block : blocks) {
+ int start = block[0];
+ int endExcl = block[1];
+ int col = block[2];
+ if (modelLine >= start && modelLine < endExcl) {
+ int x = 2 + col * guideWidthPx;
+ e.gc.drawLine(x, y, x, y + lineHeight);
+ }
+ }
+ }
+ }
+ });
+ }
+
+ /** 0-based line number for the given offset (counts '\n' before offset). */
+ private static int lineOfOffset(String text, int offset) {
+ int line = 0;
+ for (int i = 0; i < offset && i < text.length(); i++) {
+ if (text.charAt(i) == '\n') line++;
+ }
+ return line;
+ }
+
+ /** Paints "..." at the end of the first line of each collapsed fold (like
VS Code). */
+ void installCollapsePlaceholderPainter() {
+ StyledText st = sourceViewer.getTextWidget();
+ boolean dark = PropsUi.getInstance().isDarkMode();
+ Color placeholderColor =
+ new Color(st.getDisplay(), dark ? 140 : 120, dark ? 140 : 120, dark ?
140 : 120);
+ st.addDisposeListener(e -> placeholderColor.dispose());
+
+ st.addPaintListener(
+ e1 -> {
+ ProjectionViewer pv = (ProjectionViewer) sourceViewer;
+ ProjectionAnnotationModel model = pv.getProjectionAnnotationModel();
+ if (model == null) return;
+ IDocument doc = sourceViewer.getDocument();
+ if (doc == null) return;
+ ITextViewerExtension5 ext5 = (ITextViewerExtension5) sourceViewer;
+
+ e1.gc.setForeground(placeholderColor);
+ e1.gc.setFont(st.getFont());
+
+ java.util.Iterator<Annotation> it = model.getAnnotationIterator();
+ while (it.hasNext()) {
+ Annotation ann = it.next();
+ if (!(ann instanceof ProjectionAnnotation)) continue;
+ if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
+ Position pos = model.getPosition(ann);
+ if (pos == null) continue;
+ int modelLine;
+ try {
+ modelLine = doc.getLineOfOffset(pos.offset);
+ } catch (org.eclipse.jface.text.BadLocationException ex) {
+ continue;
+ }
+ int widgetLine = ext5.modelLine2WidgetLine(modelLine);
+ if (widgetLine < 0 || widgetLine >= st.getLineCount()) continue;
+ int lineEndOffset;
+ try {
+ int lineStart = st.getOffsetAtLine(widgetLine);
+ int lineLen = st.getLine(widgetLine).length();
+ lineEndOffset = lineStart + lineLen;
+ } catch (Exception ex) {
+ continue;
+ }
+ org.eclipse.swt.graphics.Point pt =
st.getLocationAtOffset(lineEndOffset);
+ if (pt != null) {
+ e1.gc.drawText("...", pt.x + 4, pt.y);
+ }
+ }
+ });
+
+ // Click on "..." placeholder expands the collapsed fold
+ final int placeholderClickWidth = 24;
+ st.addMouseListener(
+ new org.eclipse.swt.events.MouseAdapter() {
+ @Override
+ public void mouseUp(org.eclipse.swt.events.MouseEvent e) {
+ if (e.button != 1) return;
+ ProjectionViewer pv = (ProjectionViewer) sourceViewer;
+ ProjectionAnnotationModel model =
pv.getProjectionAnnotationModel();
+ if (model == null) return;
+ IDocument doc = sourceViewer.getDocument();
+ if (doc == null) return;
+ ITextViewerExtension5 ext5 = (ITextViewerExtension5) sourceViewer;
+
+ int lineHeight = st.getLineHeight();
+ java.util.Iterator<Annotation> it = model.getAnnotationIterator();
+ while (it.hasNext()) {
+ Annotation ann = it.next();
+ if (!(ann instanceof ProjectionAnnotation)) continue;
+ if (!((ProjectionAnnotation) ann).isCollapsed()) continue;
+ Position pos = model.getPosition(ann);
+ if (pos == null) continue;
+ int modelLine;
+ try {
+ modelLine = doc.getLineOfOffset(pos.offset);
+ } catch (org.eclipse.jface.text.BadLocationException ex) {
+ continue;
+ }
+ int widgetLine = ext5.modelLine2WidgetLine(modelLine);
+ if (widgetLine < 0 || widgetLine >= st.getLineCount()) continue;
+ int lineEndOffset;
+ try {
+ int lineStart = st.getOffsetAtLine(widgetLine);
+ int lineLen = st.getLine(widgetLine).length();
+ lineEndOffset = lineStart + lineLen;
+ } catch (Exception ex) {
+ continue;
+ }
+ org.eclipse.swt.graphics.Point pt =
st.getLocationAtOffset(lineEndOffset);
+ if (pt == null) continue;
+ int x1 = pt.x + 4;
+ int y1 = pt.y;
+ if (e.x >= x1
+ && e.x <= x1 + placeholderClickWidth
+ && e.y >= y1
+ && e.y < y1 + lineHeight) {
+ model.expand(ann);
+ return;
+ }
+ }
+ }
+ });
+ }
+
+ private void scheduleFoldUpdate() {
+ Display display = control.getDisplay();
+ if (display == null || display.isDisposed()) return;
+ if (pendingFoldUpdate != null) {
+ display.timerExec(-1, pendingFoldUpdate);
+ }
+ pendingFoldUpdate =
+ () -> {
+ if (!control.isDisposed()) {
+ updateFoldingRegions();
+ }
+ pendingFoldUpdate = null;
+ };
+ display.timerExec(500, pendingFoldUpdate);
+ }
+
+ private void updateFoldingRegions() {
+ ProjectionAnnotationModel model =
sourceViewer.getProjectionAnnotationModel();
+ if (model == null) return;
+
+ if (pendingFoldUpdate != null && !control.isDisposed()) {
+ control.getDisplay().timerExec(-1, pendingFoldUpdate);
+ pendingFoldUpdate = null;
+ }
+
+ String text = getText();
+ List<Position> regions;
+ if ("json".equalsIgnoreCase(languageId)) {
+ regions = computeJsonFoldRegions(text);
+ } else if ("xml".equalsIgnoreCase(languageId)) {
+ regions = computeXmlFoldRegions(text);
+ } else {
+ regions = List.of();
+ }
+
+ Annotation[] deletions = currentFoldAnnotations.toArray(new Annotation[0]);
+ Map<ProjectionAnnotation, Position> additions = new HashMap<>();
+ List<ProjectionAnnotation> newAnnotations = new ArrayList<>();
+ for (Position pos : regions) {
+ ProjectionAnnotation annotation = new ProjectionAnnotation();
+ additions.put(annotation, pos);
+ newAnnotations.add(annotation);
+ }
+
+ model.modifyAnnotations(deletions, additions, null);
+ currentFoldAnnotations = newAnnotations;
+ }
+
+ private static List<Position> computeJsonFoldRegions(String text) {
+ List<Position> regions = new ArrayList<>();
+ Deque<Integer> stack = new ArrayDeque<>();
+ boolean inString = false;
+
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (inString) {
+ if (c == '\\' && i + 1 < text.length()) {
+ i++;
+ continue;
+ }
+ if (c == '"') inString = false;
+ continue;
+ }
+ if (c == '"') {
+ inString = true;
+ continue;
+ }
+ if (c == '{' || c == '[') {
+ stack.push(i);
+ } else if (c == '}' || c == ']') {
+ if (!stack.isEmpty()) {
+ int start = stack.pop();
+ if (spanMultipleLines(text, start, i)) {
+ regions.add(new Position(start, i - start + 1));
+ }
+ }
+ }
+ }
+ return regions;
+ }
+
+ private static List<Position> computeXmlFoldRegions(String text) {
+ List<Position> regions = new ArrayList<>();
+
+ // Fold multi-line comments
+ int idx = 0;
+ while (idx < text.length()) {
+ int cs = text.indexOf("<!--", idx);
+ if (cs < 0) break;
+ int ce = text.indexOf("-->", cs + 4);
+ if (ce < 0) break;
+ ce += 3;
+ if (spanMultipleLines(text, cs, ce)) {
+ regions.add(new Position(cs, ce - cs));
+ }
+ idx = ce;
+ }
+
+ // Fold multi-line CDATA
+ idx = 0;
+ while (idx < text.length()) {
+ int cs = text.indexOf("<![CDATA[", idx);
+ if (cs < 0) break;
+ int ce = text.indexOf("]]>", cs + 9);
+ if (ce < 0) break;
+ ce += 3;
+ if (spanMultipleLines(text, cs, ce)) {
+ regions.add(new Position(cs, ce - cs));
+ }
+ idx = ce;
+ }
+
+ // Fold matching open/close tags
+ Deque<String> nameStack = new ArrayDeque<>();
+ Deque<Integer> posStack = new ArrayDeque<>();
+ int i = 0;
+ while (i < text.length()) {
+ if (text.charAt(i) != '<') {
+ i++;
+ continue;
+ }
+ if (text.startsWith("<!--", i) || text.startsWith("<![CDATA[", i)) {
+ int skip =
+ text.startsWith("<!--", i) ? text.indexOf("-->", i + 4) :
text.indexOf("]]>", i + 9);
+ i = (skip < 0) ? text.length() : skip + 3;
+ continue;
+ }
+ if (text.startsWith("<?", i) || text.startsWith("<!", i)) {
+ int gt = text.indexOf('>', i);
+ i = (gt < 0) ? text.length() : gt + 1;
+ continue;
+ }
+
+ // Close tag
+ if (text.startsWith("</", i)) {
+ int gt = text.indexOf('>', i);
+ if (gt < 0) break;
+ String closeName = text.substring(i + 2, gt).trim();
+ Deque<String> tmpNames = new ArrayDeque<>();
+ Deque<Integer> tmpPos = new ArrayDeque<>();
+ boolean found = false;
+ while (!nameStack.isEmpty()) {
+ String n = nameStack.pop();
+ int p = posStack.pop();
+ if (n.equals(closeName)) {
+ int end = gt + 1;
+ if (spanMultipleLines(text, p, end)) {
+ regions.add(new Position(p, end - p));
+ }
+ found = true;
+ break;
+ }
+ tmpNames.push(n);
+ tmpPos.push(p);
+ }
+ if (!found) {
+ while (!tmpNames.isEmpty()) {
+ nameStack.push(tmpNames.pop());
+ posStack.push(tmpPos.pop());
+ }
+ }
+ i = gt + 1;
+ continue;
+ }
+
+ // Open tag
+ int gt = text.indexOf('>', i);
+ if (gt < 0) break;
+ boolean selfClosing = text.charAt(gt - 1) == '/';
+ if (!selfClosing) {
+ int nameStart = i + 1;
+ int nameEnd = nameStart;
+ while (nameEnd < text.length()) {
+ char ch = text.charAt(nameEnd);
+ if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '>'
|| ch == '/') break;
+ nameEnd++;
+ }
+ String tagName = text.substring(nameStart, nameEnd);
+ if (!tagName.isEmpty()) {
+ nameStack.push(tagName);
+ posStack.push(i);
+ }
+ }
+ i = gt + 1;
+ }
+
+ return regions;
+ }
+
+ private static boolean spanMultipleLines(String text, int start, int end) {
+ for (int i = start; i < end && i < text.length(); i++) {
+ if (text.charAt(i) == '\n') return true;
+ }
+ return false;
+ }
+
+ /**
+ * When language is XML, typing '>' after an open tag inserts the closing
tag and places the caret
+ * between '>' and '</tagname>'.
+ */
+ void installXmlAutoClose() {
+ sourceViewer.appendVerifyKeyListener(
+ new VerifyKeyListener() {
+ @Override
+ public void verifyKey(VerifyEvent event) {
+ if (event.character != '>' || !"xml".equalsIgnoreCase(languageId))
{
+ return;
+ }
+ IDocument doc = sourceViewer.getDocument();
+ if (doc == null) return;
+ int offset = sourceViewer.getSelectedRange().x;
+ String before = "";
+ try {
+ before = offset > 0 ? doc.get(0, offset) : "";
+ } catch (org.eclipse.jface.text.BadLocationException e) {
+ return;
+ }
+ String tagName = findOpenTagNameBefore(before);
+ if (tagName == null) return;
+ event.doit = false;
+ try {
+ doc.replace(offset, 0, "></" + tagName + ">");
+ sourceViewer.setSelectedRange(offset + 1, 0);
+ } catch (org.eclipse.jface.text.BadLocationException e) {
+ // ignore
+ }
+ }
+
+ private String findOpenTagNameBefore(String text) {
+ int i = text.length() - 1;
+ while (i >= 0 && text.charAt(i) != '<') i--;
+ if (i < 0) return null;
+ if (i + 1 < text.length()) {
+ char next = text.charAt(i + 1);
+ if (next == '/' || next == '?' || next == '!') return null;
+ }
+ StringBuilder name = new StringBuilder();
+ for (i = i + 1; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (ch == ' ' || ch == '\t' || ch == '>' || ch == '/') break;
+ if (Character.isLetterOrDigit(ch)
+ || ch == ':'
+ || ch == '-'
+ || ch == '_'
+ || ch == '.') {
+ name.append(ch);
+ } else {
+ break;
+ }
+ }
+ return name.isEmpty() ? null : name.toString();
+ }
+ });
+ }
+
+ /**
+ * Annotation access for the fold ruler column: paints chevron icons and
reports annotation
+ * metadata to the {@link AnnotationRulerColumn}.
+ */
+ private static class FoldingAnnotationAccess
+ implements IAnnotationAccess, IAnnotationAccessExtension {
+
+ @Override
+ public Object getType(Annotation annotation) {
+ return annotation.getType();
+ }
+
+ @Override
+ public boolean isMultiLine(Annotation annotation) {
+ return true;
+ }
+
+ @Override
+ public boolean isTemporary(Annotation annotation) {
+ return true;
+ }
+
+ @Override
+ public String getTypeLabel(Annotation annotation) {
+ return annotation.getText() != null ? annotation.getText() : "";
+ }
+
+ @Override
+ public int getLayer(Annotation annotation) {
+ return 0;
+ }
+
+ @Override
+ public Object[] getSupertypes(Object annotationType) {
+ return new Object[0];
+ }
+
+ @Override
+ public boolean isSubtype(Object annotationType, Object potentialSupertype)
{
+ return annotationType != null &&
annotationType.equals(potentialSupertype);
+ }
+
+ @Override
+ public boolean isPaintable(Annotation annotation) {
+ return annotation instanceof ProjectionAnnotation;
+ }
+
+ @Override
+ public void paint(Annotation annotation, GC gc, Canvas canvas, Rectangle
rectangle) {
+ if (!(annotation instanceof ProjectionAnnotation pa)) return;
+
+ boolean collapsed = pa.isCollapsed();
+ int x = rectangle.x;
+ int y = rectangle.y;
+ int w = rectangle.width;
+ int lineHeight = 14;
+ int h = Math.min(rectangle.height, lineHeight);
+ if (w < 5 || h < 5) return;
+
+
gc.setForeground(canvas.getDisplay().getSystemColor(SWT.COLOR_WIDGET_FOREGROUND));
+ gc.setAntialias(SWT.ON);
+ gc.setLineWidth(1);
+ int pad = 2;
+ int cx = x + w / 2;
+ int cy = y + h / 2;
+ if (collapsed) {
+ // Right-pointing chevron: >
+ int left = x + pad;
+ int right = x + w - pad;
+ int top = y + pad;
+ int bottom = y + h - pad;
+ gc.drawLine(left, top, right, cy);
+ gc.drawLine(right, cy, left, bottom);
+ } else {
+ // Down-pointing chevron: v
+ int left = x + pad;
+ int right = x + w - pad;
+ int top = y + pad;
+ int bottom = y + h - pad;
+ gc.drawLine(left, top, cx, bottom);
+ gc.drawLine(cx, bottom, right, top);
+ }
+ }
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
b/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
index b029792d3c..8ca4cc7785 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.core.gui;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
import java.util.List;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.gui.plugin.GuiRegistry;
@@ -193,21 +194,28 @@ public class BaseGuiWidgets {
return e -> {
try {
// See if we can find a static method which accepts this instance as
an argument.
- // What's the registered GUI object we have?
+ // Parameter type may be the concrete class or any
interface/superclass (e.g. facade
+ // toolbars register a ContentEditorWidget but listeners take
IContentEditorWidget).
//
- try {
- Class<?> listenerClass = classLoader.loadClass(listenerClassName);
- Method listenerMethod =
- listenerClass.getMethod(listenerMethodName,
guiPluginObject.getClass());
- listenerMethod.invoke(null, guiPluginObject);
- return;
- } catch (NoSuchMethodException
- | ClassNotFoundException
- | InvocationTargetException exception) {
- // Ignore this and re-try with the standard empty method
- } catch (Exception exception) {
- // An exception thrown by the method itself
- throw exception;
+ if (guiPluginObject != null) {
+ try {
+ Class<?> listenerClass = classLoader.loadClass(listenerClassName);
+ Method listenerMethod =
+ findStaticListenerMethod(
+ listenerClass, listenerMethodName,
guiPluginObject.getClass());
+ if (listenerMethod != null) {
+ listenerMethod.invoke(null, guiPluginObject);
+ return;
+ }
+ } catch (ClassNotFoundException exception) {
+ // No such listener class — fall through to the instance method
path
+ } catch (InvocationTargetException exception) {
+ // Static method was found and threw; do not hide the error behind
a retry
+ throw exception;
+ } catch (Exception exception) {
+ // An exception thrown by the method itself
+ throw exception;
+ }
}
Object guiPluginInstance =
@@ -237,6 +245,30 @@ public class BaseGuiWidgets {
};
}
+ /**
+ * Find a public static method with a single parameter assignable from
{@code argumentType}.
+ * Prefers the most specific matching parameter type when more than one
candidate exists.
+ */
+ static Method findStaticListenerMethod(
+ Class<?> listenerClass, String methodName, Class<?> argumentType) {
+ Method best = null;
+ for (Method method : listenerClass.getMethods()) {
+ if (!methodName.equals(method.getName())
+ || !Modifier.isStatic(method.getModifiers())
+ || method.getParameterCount() != 1) {
+ continue;
+ }
+ Class<?> parameterType = method.getParameterTypes()[0];
+ if (!parameterType.isAssignableFrom(argumentType)) {
+ continue;
+ }
+ if (best == null ||
best.getParameterTypes()[0].isAssignableFrom(parameterType)) {
+ best = method;
+ }
+ }
+ return best;
+ }
+
/**
* Gets instanceId
*
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
index 9f35ca20e2..0d571930cd 100644
---
a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
+++
b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
@@ -19,6 +19,7 @@ package org.apache.hop.ui.core.widget.editor;
import org.eclipse.swt.events.ModifyListener;
import org.eclipse.swt.widgets.Control;
+import org.jspecify.annotations.Nullable;
/**
* Common interface for a content/code editor widget used in both Hop GUI
(desktop) and Hop Web.
@@ -29,6 +30,10 @@ import org.eclipse.swt.widgets.Control;
*/
public interface IContentEditorWidget {
+ public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID =
"ContentEditor-Toolbar";
+
+ public static final String GUI_PLUGIN_CONTEXT_MENU_PARENT_ID =
"ContentEditor-ContextMenu";
+
/**
* The SWT control to attach to a layout (e.g. the editor composite or the
AWT bridge canvas).
*
@@ -58,6 +63,13 @@ public interface IContentEditorWidget {
*/
void setTextSuppressModify(String text);
+ /**
+ * Get the language used for syntax highlighting and validation.
+ *
+ * @return language identifier (e.g. "json", "xml", "javascript")
+ */
+ @Nullable String getLanguage();
+
/**
* Set the language/mode used for syntax highlighting and validation.
Interpretation is
* implementation-specific; use lowercase identifiers such as "json", "xml",
"javascript".
@@ -96,4 +108,16 @@ public interface IContentEditorWidget {
/** Copy selected text to clipboard. */
void copy();
+
+ /** Cut selected text to clipboard. No-op if not supported by the
implementation. */
+ void cut();
+
+ /** Paste from clipboard at the caret. No-op if not supported by the
implementation. */
+ void paste();
+
+ /** Undo the last edit. No-op if not supported by the implementation. */
+ void undo();
+
+ /** Redo the last undone edit. No-op if not supported by the implementation.
*/
+ void redo();
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
new file mode 100644
index 0000000000..d115e59f9e
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/ContentEditorActions.java
@@ -0,0 +1,120 @@
+/*
+ * 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 it except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui;
+
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementType;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+
+/**
+ * Shared content-editor toolbar actions for Hop GUI (desktop) and Hop Web.
+ *
+ * <p>Toolbar items must live on a class present on both classpaths. The RCP
JFace editor and RAP
+ * Monaco/fallback widgets implement {@link IContentEditorWidget}; listeners
are static methods
+ * taking that interface so {@link org.apache.hop.ui.core.gui.BaseGuiWidgets}
can invoke them with
+ * the registered editor instance.
+ *
+ * <p>Context menu and keyboard shortcuts remain on the RCP {@code
ContentEditorWidget} (desktop
+ * only).
+ */
+@GuiPlugin(name = "Content editor")
+public class ContentEditorActions {
+
+ public static final String ID_TOOLBAR_UNDO =
"ContentEditor-Toolbar-10000-undo";
+ public static final String ID_TOOLBAR_REDO =
"ContentEditor-Toolbar-10010-redo";
+ public static final String ID_TOOLBAR_SELECT_ALL =
"ContentEditor-Toolbar-20000-select-all";
+ public static final String ID_TOOLBAR_UNSELECT_ALL =
"ContentEditor-Toolbar-20010-unselect-all";
+ public static final String ID_TOOLBAR_COPY =
"ContentEditor-Toolbar-30000-copy";
+ public static final String ID_TOOLBAR_PASTE =
"ContentEditor-Toolbar-30010-paste";
+ public static final String ID_TOOLBAR_CUT =
"ContentEditor-Toolbar-30020-cut";
+
+ private ContentEditorActions() {}
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_UNDO,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/undo.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.Undo.Tooltip")
+ public static void undo(IContentEditorWidget editor) {
+ editor.undo();
+ }
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_REDO,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/redo.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.Redo.Tooltip")
+ public static void redo(IContentEditorWidget editor) {
+ editor.redo();
+ }
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_SELECT_ALL,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/select-all.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.SelectAll.Tooltip",
+ separator = true)
+ public static void selectAll(IContentEditorWidget editor) {
+ editor.selectAll();
+ }
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_UNSELECT_ALL,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/unselect-all.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.UnselectAll.Tooltip")
+ public static void unselectAll(IContentEditorWidget editor) {
+ editor.unselectAll();
+ }
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_COPY,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/copy.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.Copy.Tooltip",
+ separator = true)
+ public static void copy(IContentEditorWidget editor) {
+ editor.copy();
+ }
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_CUT,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/cut.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.Cut.Tooltip")
+ public static void cut(IContentEditorWidget editor) {
+ editor.cut();
+ }
+
+ @GuiToolbarElement(
+ root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = ID_TOOLBAR_PASTE,
+ type = GuiToolbarElementType.BUTTON,
+ image = "ui/images/paste.svg",
+ toolTip = "i18n::ContentEditorWidget.ToolBar.Paste.Tooltip")
+ public static void paste(IContentEditorWidget editor) {
+ editor.paste();
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
index 2578a8fe3c..c569a2058d 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiEnvironment.java
@@ -86,9 +86,11 @@ public class HopGuiEnvironment extends HopClientEnvironment {
Class<?>[] typeClasses = guiPlugin.getClassMap().keySet().toArray(new
Class<?>[0]);
String guiPluginClassName =
guiPlugin.getClassMap().get(typeClasses[0]);
- // TextDiffDialog is the side-by-side revision compare dialog. Only
works on SWT
+ // Desktop-only GUI plugins (require SWT types not available under RAP)
if (EnvironmentUtils.getInstance().isWeb()
- && "org.apache.hop.git.TextDiffDialog".equals(guiPluginClassName))
{
+ && ("org.apache.hop.git.TextDiffDialog".equals(guiPluginClassName)
+ // Content editor JFace implementation lives in hop-ui-rcp only
+ ||
"org.apache.hop.ui.hopgui.ContentEditorWidget".equals(guiPluginClassName))) {
continue;
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
index 3f968513f6..072e2d1f04 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/ExplorerPerspective.java
@@ -2211,10 +2211,7 @@ public class ExplorerPerspective implements
IHopPerspective, TabClosable, IFileD
});
Composite composite = new Composite(targetFolder, SWT.NONE);
- FormLayout layoutComposite = new FormLayout();
- layoutComposite.marginWidth = PropsUi.getFormMargin();
- layoutComposite.marginHeight = PropsUi.getFormMargin();
- composite.setLayout(layoutComposite);
+ composite.setLayout(new FormLayout());
composite.setLayoutData(new FormDataBuilder().fullSize().result());
PropsUi.setLook(composite);
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
index 3ab23b4cf0..46e62ef8da 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/sql/SqlExplorerFileType.java
@@ -48,6 +48,8 @@ public class SqlExplorerFileType extends
BaseTextExplorerFileType<SqlExplorerFil
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
index 07d4e69aab..f14ed5b17f 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/explorer/file/types/xml/XmlExplorerFileType.java
@@ -48,6 +48,8 @@ public class XmlExplorerFileType extends
BaseTextExplorerFileType<XmlExplorerFil
IHopFileType.CAPABILITY_CLOSE,
IHopFileType.CAPABILITY_FILE_HISTORY,
IHopFileType.CAPABILITY_COPY,
+ IHopFileType.CAPABILITY_CUT,
+ IHopFileType.CAPABILITY_PASTE,
IHopFileType.CAPABILITY_SELECT));
}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index e1a614bfed..0aeb25e71a 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -326,4 +326,20 @@ HopGui.LayoutCheck.Dialog.MismatchMessage=Details of row
layout mismatches:
HopGui.LayoutCheck.Dialog.ReplaceDummyTitle=Replace Transform
HopGui.LayoutCheck.Dialog.ReplaceDummyMessage=Do you want to replace this
Dummy transform with a ''Stream Schema Merge'' transform?
HopGui.LayoutCheck.Dialog.StreamSchemaPluginNotFound=Stream Schema Merge
plugin not found.
-HopGui.LayoutCheck.Dialog.ReplaceDummyError=Error replacing Dummy transform
with Stream Schema Merge
\ No newline at end of file
+HopGui.LayoutCheck.Dialog.ReplaceDummyError=Error replacing Dummy transform
with Stream Schema Merge
+ContentEditorWidget.Menu.Copy=Copy
+ContentEditorWidget.Menu.Cut=Cut
+ContentEditorWidget.Menu.Paste=Paste
+ContentEditorWidget.Menu.Redo=Redo
+ContentEditorWidget.Menu.SelectAll=Select All
+ContentEditorWidget.Menu.UnselectAll=Clear selection
+ContentEditorWidget.Menu.Undo=Undo
+ContentEditorWidget.ToolBar.SelectAll.Tooltip=Select all text
+ContentEditorWidget.ToolBar.UnselectAll.Tooltip=Clear selection
+ContentEditorWidget.ToolBar.Copy.Tooltip=Copy selected text to clipboard
+ContentEditorWidget.ToolBar.Cut.Tooltip=Cut selected text to clipboard
+ContentEditorWidget.ToolBar.Paste.Tooltip=Paste text from clipboard
+ContentEditorWidget.ToolBar.Undo.Tooltip=Undo the last action
+ContentEditorWidget.ToolBar.Redo.Tooltip=Redo the last action
+ContentEditorWidget.ToolBar.Find.Tooltip=Find text
+ContentEditorWidget.ToolBar.Replace.Tooltip=Replace text
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/gui/BaseGuiWidgetsStaticListenerTest.java
b/ui/src/test/java/org/apache/hop/ui/core/gui/BaseGuiWidgetsStaticListenerTest.java
new file mode 100644
index 0000000000..5c4c55c57c
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/core/gui/BaseGuiWidgetsStaticListenerTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies static toolbar listener resolution accepts interface/superclass
parameters (facade
+ * pattern used by the content editor toolbar).
+ */
+class BaseGuiWidgetsStaticListenerTest {
+
+ interface EditorFacade {
+ String id();
+ }
+
+ static final class ConcreteEditor implements EditorFacade {
+ @Override
+ public String id() {
+ return "concrete";
+ }
+ }
+
+ static final class ListenerHost {
+ static EditorFacade lastFacade;
+ static ConcreteEditor lastConcrete;
+
+ public static void onFacade(EditorFacade editor) {
+ lastFacade = editor;
+ }
+
+ public static void onConcrete(ConcreteEditor editor) {
+ lastConcrete = editor;
+ }
+
+ public static void ignored(String other) {
+ // not a match for EditorFacade argument
+ }
+ }
+
+ @Test
+ void findsStaticMethodWithInterfaceParameter() throws Exception {
+ Method method =
+ BaseGuiWidgets.findStaticListenerMethod(
+ ListenerHost.class, "onFacade", ConcreteEditor.class);
+ assertNotNull(method);
+ assertEquals("onFacade", method.getName());
+
+ ConcreteEditor editor = new ConcreteEditor();
+ method.invoke(null, editor);
+ assertSame(editor, ListenerHost.lastFacade);
+ }
+
+ @Test
+ void prefersMostSpecificParameterType() {
+ Method method =
+ BaseGuiWidgets.findStaticListenerMethod(
+ ListenerHost.class, "onConcrete", ConcreteEditor.class);
+ assertNotNull(method);
+ assertEquals(ConcreteEditor.class, method.getParameterTypes()[0]);
+ }
+
+ @Test
+ void returnsNullWhenNoAssignableParameter() {
+ Method method =
+ BaseGuiWidgets.findStaticListenerMethod(
+ ListenerHost.class, "ignored", ConcreteEditor.class);
+ assertNull(method);
+ }
+}