From b65a06942379bd0dec7487af2ec3dbddfabf2ee3 Mon Sep 17 00:00:00 2001 From: Nicolas Adment <39568358+nadment@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:15:36 +0200 Subject: [PATCH] Add a context menu and missing capability to the text editor #7549 --- .gitignore | 1 + .../types/JsonExplorerFileType.java | 2 + .../transforms/types/BatExplorerFileType.java | 2 + .../transforms/types/CsvExplorerFileType.java | 2 + .../types/MarkDownExplorerFileType.java | 2 + .../MarkDownExplorerFileTypeHandler.java | 65 +- .../types/PythonExplorerFileType.java | 2 + .../types/ShellExplorerFileType.java | 2 + .../types/TextExplorerFileType.java | 2 + .../ui/hopgui/ContentEditorFacadeImpl.java | 1034 +------------- .../hop/ui/hopgui/ContentEditorWidget.java | 1262 +++++++++++++++++ .../widget/editor/IContentEditorWidget.java | 12 + .../explorer/ExplorerPerspective.java | 5 +- .../file/types/sql/SqlExplorerFileType.java | 2 + .../file/types/xml/XmlExplorerFileType.java | 2 + .../hopgui/messages/messages_en_US.properties | 24 +- 16 files changed, 1344 insertions(+), 1077 deletions(-) create mode 100644 rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java diff --git a/.gitignore b/.gitignore index c4d839d0398..2b834a6218d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ release.properties pom.xml.releaseBackup *.iml .idea +.junie bin *.class *.jar 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 fc19743704f..c9d11197b3a 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 JsonExplorerFileType() { 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 e3d17966861..e8971425979 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 BatExplorerFileType() { 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 0e6170ca533..30dbeb36698 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 CsvExplorerFileType() { 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 bf0112640f0..169d6351bf1 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 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 00ec78346f4..437e074759e 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,16 @@ 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.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 +44,16 @@ 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 +68,17 @@ public MarkDownExplorerFileTypeHandler( @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 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,14 +95,26 @@ public void renderFile(Composite composite) { }); } + @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") + image = "ui/images/preview.svg", + separator = true) + // public static void previewMarkdown(IContentEditorWidget editorWidget) { public void previewMarkdown() { try { + // IContentEditorWidget editorWidget = (IContentEditorWidget) event.widget; String markdown = editorWidget.getText(); // Parse markdown to HTML body content @@ -304,7 +288,10 @@ public void previewMarkdown() { 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 3d61448134d..3cef1040c78 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 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 c95ae4f29c1..c8ecd00dd06 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 ShellExplorerFileType() { 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 094a47ebc27..95fbee298cc 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 TextExplorerFileType() { IHopFileType.CAPABILITY_CLOSE, IHopFileType.CAPABILITY_FILE_HISTORY, IHopFileType.CAPABILITY_COPY, + IHopFileType.CAPABILITY_CUT, + IHopFileType.CAPABILITY_PASTE, IHopFileType.CAPABILITY_SELECT)); } 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 9c63b195d3f..4a5d4ab6c9a 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 @@ * 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 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 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 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 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 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 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 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 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 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 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 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 additions = new HashMap<>(); - List 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 computeJsonFoldRegions(String text) { - List regions = new ArrayList<>(); - Deque 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 computeXmlFoldRegions(String text) { - List regions = new ArrayList<>(); - - // Fold multi-line comments - int idx = 0; - while (idx < text.length()) { - int cs = 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("", 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 nameStack = new ArrayDeque<>(); - Deque posStack = new ArrayDeque<>(); - int i = 0; - while (i < text.length()) { - if (text.charAt(i) != '<') { - i++; - continue; - } - if (text.startsWith("", i + 4) : text.indexOf("]]>", i + 9); - i = (skip < 0) ? text.length() : skip + 3; - continue; - } - if (text.startsWith("', i); - i = (gt < 0) ? text.length() : gt + 1; - continue; - } - - // Close tag - if (text.startsWith("', i); - if (gt < 0) break; - String closeName = text.substring(i + 2, gt).trim(); - Deque tmpNames = new ArrayDeque<>(); - Deque 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 ''. - */ - 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, ">"); - 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 00000000000..8c17cd1006d --- /dev/null +++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java @@ -0,0 +1,1262 @@ +/* + * 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.core.gui.plugin.toolbar.GuiToolbarElement; +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.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 native SWT: Eclipse JFace SourceViewer + * with rule-based syntax highlighting (XML, JSON, SQL), bracket matching, and XML auto-closing + * tags. + */ +@GuiPlugin(name = "Content editor") +public class ContentEditorWidget implements IContentEditorWidget { + private static final Class PKG = ContentEditorWidget.class; + + 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"; + public static final String ID_TOOLBAR_FIND = "ContentEditor-Toolbar-40000-find"; + + 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 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 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 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(ID_TOOLBAR_UNDO, canUndo); + toolbarWidgets.enableToolbarItem(ID_TOOLBAR_REDO, canRedo); + toolbarWidgets.enableToolbarItem(ID_TOOLBAR_CUT, canCut); + toolbarWidgets.enableToolbarItem(ID_TOOLBAR_COPY, canCopy); + toolbarWidgets.enableToolbarItem(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") + @GuiToolbarElement( + root = GUI_PLUGIN_TOOLBAR_PARENT_ID, + id = ID_TOOLBAR_UNDO, + type = GuiToolbarElementType.BUTTON, + image = "ui/images/undo.svg", + toolTip = "i18n::ContentEditorWidget.ToolBar.Undo.Tooltip") + @GuiKeyboardShortcut(control = true, key = 'z') + @GuiOsxKeyboardShortcut(command = true, key = 'z') + 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") + @GuiToolbarElement( + root = GUI_PLUGIN_TOOLBAR_PARENT_ID, + id = ID_TOOLBAR_REDO, + type = GuiToolbarElementType.BUTTON, + image = "ui/images/redo.svg", + toolTip = "i18n::ContentEditorWidget.ToolBar.Redo.Tooltip") + @GuiKeyboardShortcut(control = true, shift = true, key = 'z') + @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'z') + 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) + @GuiToolbarElement( + root = 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) + @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") + @GuiToolbarElement( + root = 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") + @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) + @GuiToolbarElement( + root = 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) + @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") + @GuiToolbarElement( + root = GUI_PLUGIN_TOOLBAR_PARENT_ID, + id = ID_TOOLBAR_CUT, + type = GuiToolbarElementType.BUTTON, + image = "ui/images/cut.svg", + toolTip = "i18n::ContentEditorWidget.ToolBar.Cut.Tooltip") + @GuiKeyboardShortcut(control = true, key = 'x') + @GuiOsxKeyboardShortcut(command = true, key = 'x') + 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") + @GuiToolbarElement( + root = GUI_PLUGIN_TOOLBAR_PARENT_ID, + id = ID_TOOLBAR_PASTE, + type = GuiToolbarElementType.BUTTON, + image = "ui/images/paste.svg", + toolTip = "i18n::ContentEditorWidget.ToolBar.Paste.Tooltip") + @GuiKeyboardShortcut(control = true, key = 'v') + @GuiOsxKeyboardShortcut(command = true, key = 'v') + 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 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 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 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 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 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 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 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 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 additions = new HashMap<>(); + List 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 computeJsonFoldRegions(String text) { + List regions = new ArrayList<>(); + Deque 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 computeXmlFoldRegions(String text) { + List regions = new ArrayList<>(); + + // Fold multi-line comments + int idx = 0; + while (idx < text.length()) { + int cs = 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("", 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 nameStack = new ArrayDeque<>(); + Deque posStack = new ArrayDeque<>(); + int i = 0; + while (i < text.length()) { + if (text.charAt(i) != '<') { + i++; + continue; + } + if (text.startsWith("", i + 4) : text.indexOf("]]>", i + 9); + i = (skip < 0) ? text.length() : skip + 3; + continue; + } + if (text.startsWith("', i); + i = (gt < 0) ? text.length() : gt + 1; + continue; + } + + // Close tag + if (text.startsWith("', i); + if (gt < 0) break; + String closeName = text.substring(i + 2, gt).trim(); + Deque tmpNames = new ArrayDeque<>(); + Deque 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 ''. + */ + 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, ">"); + 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/widget/editor/IContentEditorWidget.java b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java index 9f35ca20e2a..79f6fbff64f 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 @@ 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 @@ */ 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". 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 3f968513f6b..072e2d1f04b 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 void contentSafe(Object parentObject) { }); 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 3ab23b4cf0c..46e62ef8da1 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 SqlExplorerFileType() { 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 07d4e69aabb..f14ed5b17f0 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 XmlExplorerFileType() { 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 e1a614bfed1..e7dad900150 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,26 @@ 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.Undo=Undo +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 \ No newline at end of file