diff --git a/.github/build.sh b/.github/build.sh new file mode 100755 index 00000000..7da42622 --- /dev/null +++ b/.github/build.sh @@ -0,0 +1,3 @@ +#!/bin/sh +curl -fsLO https://raw.githubusercontent.com/scijava/scijava-scripts/master/ci-build.sh +sh ci-build.sh diff --git a/.github/setup.sh b/.github/setup.sh new file mode 100755 index 00000000..f359bbee --- /dev/null +++ b/.github/setup.sh @@ -0,0 +1,3 @@ +#!/bin/sh +curl -fsLO https://raw.githubusercontent.com/scijava/scijava-scripts/master/ci-setup-github-actions.sh +sh ci-setup-github-actions.sh diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml new file mode 100644 index 00000000..5ef56920 --- /dev/null +++ b/.github/workflows/build-main.yml @@ -0,0 +1,32 @@ +name: build + +on: + push: + branches: + - master + tags: + - "*-[0-9]+.*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Java + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'zulu' + cache: 'maven' + - name: Set up CI environment + run: .github/setup.sh + - name: Execute the build + run: .github/build.sh + env: + GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASS: ${{ secrets.MAVEN_PASS }} + OSSRH_PASS: ${{ secrets.OSSRH_PASS }} + SIGNING_ASC: ${{ secrets.SIGNING_ASC }} diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 00000000..925b5765 --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,23 @@ +name: build PR + +on: + pull_request: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Java + uses: actions/setup-java@v3 + with: + java-version: '8' + distribution: 'zulu' + cache: 'maven' + - name: Set up CI environment + run: .github/setup.sh + - name: Execute the build + run: .github/build.sh diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 537dd042..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: java -jdk: openjdk8 -branches: - only: - - master - - "/.*-[0-9]+\\..*/" -install: true -script: ".travis/build.sh" -cache: - directories: - - "~/.m2/repository" -env: - global: - - secure: RuQN2nFtAw8NsMRJCq6pdELvuFl82q7M1d7SiSQAMv4y0QJqMLQzN+FZdzOkyfYD/npm7Nr2NZCRT1zPbEK1ociWwcB7CBB709IRw+TQSVn5WjBCaiAZGhjGT+n44YKLg+RDmSzReLI2m7xsxO8uE9skSZ3e59qma7ImjKp0FvU= - - secure: nqV+UJkP2tRjztAPdSdI6VTmzMn5hNbOM+Eb6ik5QGINuMaA6g96sEM8HiPwS7lFcQhknr3s5OwK9vXe9OzN9eWd0ndctnW9HXvySjfvouwhimRf98r/70chduaQ5dQibo+E4I0V6cZe6PnyNCG/H0X4gEBZCo/sbEwlbTmIvoc= - - secure: smJ7AvKLnfcIZBitWdjT4EByy58PAtVdQ8idhurBes+VJ0fWkBs3hJkAonamqMbW/NSXC0ft3xp7VX11UfLFFCCBCDxanD7rFAdKG49EiHX4MqNWCDZgsjhZ8ALMm9bR0xa5wuce4x7F6AY6IoPPkXhY2AIEzeYETnoqlPS1rUo= - - secure: Vy84z6txtJWjsRbZhVNU1apB/lEjRrq+cTR/XfGuBr5lXP9gjHlcuU/zKGxttIkeU0K0r+Axu+UYVGLwxaCF9zoLBm7IReSXKvg6dbGMthikHM/QGrIKFCUt5VJZI6aGznfV/PIrHKAOiYQvoj1qG/Fdy88lqn3UkbRw65rb8Ug= diff --git a/.travis/build.sh b/.travis/build.sh deleted file mode 100755 index 59edbbc7..00000000 --- a/.travis/build.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -curl -fsLO https://raw.githubusercontent.com/scijava/scijava-scripts/master/travis-build.sh -sh travis-build.sh $encrypted_ab06c4370b0e_key $encrypted_ab06c4370b0e_iv diff --git a/.travis/signingkey.asc.enc b/.travis/signingkey.asc.enc deleted file mode 100644 index 62d1d8aa..00000000 Binary files a/.travis/signingkey.asc.enc and /dev/null differ diff --git a/LICENSE.txt b/LICENSE.txt index 7d75d811..142f2665 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2009 - 2020, SciJava developers. +Copyright (c) 2009 - 2025, SciJava developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, diff --git a/README.md b/README.md index fccda573..cbca8bfc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ -[![](https://travis-ci.org/scijava/script-editor.svg?branch=master)](https://travis-ci.org/scijava/script-editor) +[![](https://github.com/scijava/script-editor/actions/workflows/build-main.yml/badge.svg)](https://github.com/scijava/script-editor/actions/workflows/build-main.yml) SciJava Script Editor --------------------- Script Editor and Interpreter for SciJava script languages. + +## Testing from the command line + +### Script Editor + +``` +mvn -Pexec,editor +``` + +### Script Interpreter + +``` +mvn -Pexec,interp +``` diff --git a/pom.xml b/pom.xml index 52b11fbb..f15c904e 100644 --- a/pom.xml +++ b/pom.xml @@ -1,16 +1,16 @@ - + 4.0.0 org.scijava pom-scijava - 29.2.1 + 43.0.0 script-editor - 0.5.8-SNAPSHOT + 1.2.1-SNAPSHOT SciJava Script Editor Script Editor and Interpreter for SciJava script languages. @@ -125,7 +125,7 @@ - scm:git:git://github.com/scijava/script-editor + scm:git:https://github.com/scijava/script-editor scm:git:git@github.com:scijava/script-editor HEAD https://github.com/scijava/script-editor @@ -135,25 +135,21 @@ https://github.com/scijava/script-editor/issues - Travis CI - https://travis-ci.org/scijava/script-editor + GitHub Actions + https://github.com/scijava/script-editor/actions + org.scijava.ui.swing.script.Main org.scijava.ui.swing.script bsd_2 SciJava developers. - deploy-to-scijava - + sign,deploy-to-scijava - - - scijava.public - https://maven.scijava.org/content/groups/public - - + 3.6.0 + @@ -161,6 +157,10 @@ org.scijava scijava-common + + org.scijava + scijava-search + org.scijava scripting-java @@ -178,7 +178,6 @@ com.miglayout miglayout-swing - ${miglayout-swing.version} org.scijava @@ -206,8 +205,19 @@ scripting-groovy test + + com.formdev + flatlaf + + + + scijava.public + https://maven.scijava.org/content/groups/public + + + diff --git a/src/main/java/org/scijava/ui/swing/script/AutoImporter.java b/src/main/java/org/scijava/ui/swing/script/AutoImporter.java index 3b4b856b..e6e4f64f 100644 --- a/src/main/java/org/scijava/ui/swing/script/AutoImporter.java +++ b/src/main/java/org/scijava/ui/swing/script/AutoImporter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/Bookmark.java b/src/main/java/org/scijava/ui/swing/script/Bookmark.java index 01a8905d..c02a7d6f 100644 --- a/src/main/java/org/scijava/ui/swing/script/Bookmark.java +++ b/src/main/java/org/scijava/ui/swing/script/Bookmark.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/BookmarkDialog.java b/src/main/java/org/scijava/ui/swing/script/BookmarkDialog.java index 8f37c723..13b9cfe6 100644 --- a/src/main/java/org/scijava/ui/swing/script/BookmarkDialog.java +++ b/src/main/java/org/scijava/ui/swing/script/BookmarkDialog.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/CommandPalette.java b/src/main/java/org/scijava/ui/swing/script/CommandPalette.java new file mode 100644 index 00000000..de48bfca --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/CommandPalette.java @@ -0,0 +1,935 @@ +/*- + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Insets; +import java.awt.Rectangle; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import javax.swing.AbstractAction; +import javax.swing.AbstractButton; +import javax.swing.Action; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JRootPane; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.JTextField; +import javax.swing.KeyStroke; +import javax.swing.ListSelectionModel; +import javax.swing.MenuElement; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; + +import org.fife.ui.rtextarea.RecordableTextAction; +import org.scijava.util.PlatformUtils; + +class CommandPalette { + + private static final String NAME = "Command Palette..."; + + /** Settings. Ought to become adjustable some day */ + private static final KeyStroke ACCELERATOR = KeyStroke.getKeyStroke(KeyEvent.VK_P, + java.awt.Toolkit.getDefaultToolkit().getMenuShortcutKeyMask() | KeyEvent.SHIFT_DOWN_MASK); + private static final int TABLE_ROWS = 6; // no. of commands to be displayed + private static final float OPACITY = 1f; // 0-1 range + private static final boolean IGNORE_WHITESPACE = true; // Ignore white spaces while matching? + private static Palette frame; + + private SearchField searchField; + private CmdTable table; + private final TextEditor textEditor; + private final CmdAction noHitsCmd; + private final CmdScrapper cmdScrapper; + + public CommandPalette(final TextEditor textEditor) { + this.textEditor = textEditor; + noHitsCmd = new SearchWebCmd(); + cmdScrapper = new CmdScrapper(textEditor); + } + + void install(final JMenu toolsMenu) { + final Action action = new AbstractAction(NAME) { + + private static final long serialVersionUID = -7030359886427866104L; + + @Override + public void actionPerformed(final ActionEvent e) { + toggleVisibility(); + } + + }; + action.putValue(Action.ACCELERATOR_KEY, ACCELERATOR); + toolsMenu.add(new JMenuItem(action)); + } + + Map getShortcuts() { + if (!cmdScrapper.scrapeSuccessful()) + cmdScrapper.scrape(); + final TreeMap result = new TreeMap<>(); + cmdScrapper.getCmdMap().forEach((id, cmdAction) -> { + if (cmdAction.hotkey != null && !cmdAction.hotkey.isEmpty()) + result.put(id, cmdAction.hotkey); + + }); + return result; + } + + Map getRecordableActions() { + if (!cmdScrapper.scrapeSuccessful()) + cmdScrapper.scrape(); + final TreeMap result = new TreeMap<>(); + cmdScrapper.getCmdMap().forEach((id, cmdAction) -> { + if (cmdAction.recordable()) + result.put(id, cmdAction.hotkey); + + }); + return result; + } + + void register(final AbstractButton button, final String description) { + cmdScrapper.registerOther(button, description); + } + + void dispose() { + if (frame != null) frame.dispose(); + frame = null; + } + + private void hideWindow() { + if (frame != null) frame.setVisible(false); + } + + private void assemblePalette() { + if (frame != null) + return; + frame = new Palette(); + frame.setLayout(new BorderLayout()); + searchField = new SearchField(); + frame.add(searchField, BorderLayout.NORTH); + searchField.getDocument().addDocumentListener(new PromptDocumentListener()); + final InternalKeyListener keyListener = new InternalKeyListener(); + searchField.addKeyListener(keyListener); + table = new CmdTable(); + table.addKeyListener(keyListener); + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(final MouseEvent e) { + if (e.getClickCount() == 2 && table.getSelectedRow() > -1) { + runCmd(table.getInternalModel().getCommand(table.getSelectedRow())); + } + } + }); + populateList(""); + frame.add(table.getScrollPane()); + frame.pack(); + } + + private String[] makeRow(final CmdAction ca) { + return new String[] {ca.id, ca.description()}; + } + + private void populateList(final String matchingSubstring) { + final ArrayList list = new ArrayList<>(); + if (!cmdScrapper.scrapeSuccessful()) + cmdScrapper.scrape(); + cmdScrapper.getCmdMap().forEach((id, cmd) -> { + if (cmd.matches(matchingSubstring)) { + list.add(makeRow(cmd)); + } + }); + if (list.isEmpty()) { + list.add(makeRow(noHitsCmd)); + } + table.getInternalModel().setData(list); + if (searchField != null) + searchField.requestFocus(); + } + + private void runCmd(final String command) { + SwingUtilities.invokeLater(() -> { + if (CmdScrapper.REBUILD_ID.equals(command)) { + cmdScrapper.scrape(); + table.clearSelection(); + searchField.setText(""); + searchField.requestFocus(); + frame.setVisible(true); + return; + } + CmdAction cmd; + if (noHitsCmd != null && command.equals(noHitsCmd.id)) { + cmd = noHitsCmd; + } else { + cmd = cmdScrapper.getCmdMap().get(command); + } + if (cmd != null) { + final boolean hasButton = cmd.button != null; + if (hasButton && !cmd.button.isEnabled()) { + textEditor.error("Command is currently disabled. Either execution requirements " + + "are unmet or it is not supported by current language."); + frame.setVisible(true); + return; + } + hideWindow(); // hide before running, in case command opens a dialog + if (hasButton) { + cmd.button.doClick(); + } else if (cmd.action != null) { + cmd.action.actionPerformed( + new ActionEvent(textEditor.getTextArea(), ActionEvent.ACTION_PERFORMED, cmd.id)); + } + } + }); + } + + private void toggleVisibility() { + if (frame == null) { + assemblePalette(); + } + if (frame.isVisible()) { + hideWindow(); + } else { + frame.center(textEditor); + table.clearSelection(); + frame.setVisible(true); + searchField.requestFocus(); + } + } + + private static class SearchField extends TextEditor.TextFieldWithPlaceholder { + private static final long serialVersionUID = 1L; + private static final int PADDING = 4; + static final Font REF_FONT = refFont(); + + SearchField() { + setPlaceholder(" Search for commands and actions (e.g., Theme)"); + setMargin(new Insets(PADDING, PADDING, 0, 0)); + setFont(REF_FONT.deriveFont(REF_FONT.getSize() * 1.5f)); + } + + @Override + Font getPlaceholderFont() { + return REF_FONT.deriveFont(Font.ITALIC); + } + + static Font refFont() { + try { + return UIManager.getFont("TextField.font"); + } catch (final Exception ignored) { + return new JTextField().getFont(); + } + } + } + + private class PromptDocumentListener implements DocumentListener { + public void insertUpdate(final DocumentEvent e) { + populateList(getQueryFromSearchField()); + } + + public void removeUpdate(final DocumentEvent e) { + populateList(getQueryFromSearchField()); + } + + public void changedUpdate(final DocumentEvent e) { + populateList(getQueryFromSearchField()); + } + + String getQueryFromSearchField() { + final String text = searchField.getText(); + if (text == null) + return ""; + final String query = text.toLowerCase(); + return (IGNORE_WHITESPACE) ? query.replaceAll("\\s+", "") : query; + } + } + + private class InternalKeyListener extends KeyAdapter { + + @Override + public void keyPressed(final KeyEvent ke) { + final int key = ke.getKeyCode(); + final int flags = ke.getModifiersEx(); + final int items = table.getInternalModel().getRowCount(); + final Object source = ke.getSource(); + final boolean meta = ((flags & KeyEvent.META_DOWN_MASK) != 0) || ((flags & KeyEvent.CTRL_DOWN_MASK) != 0); + if (key == KeyEvent.VK_ESCAPE || (key == KeyEvent.VK_W && meta) || (key == KeyEvent.VK_P && meta)) { + hideWindow(); + } else if (source == searchField) { + /* + * If you hit enter in the text field, and there's only one command that + * matches, run that: + */ + if (key == KeyEvent.VK_ENTER) { + if (1 == items) + runCmd(table.getInternalModel().getCommand(0)); + } + /* + * If you hit the up or down arrows in the text field, move the focus to the + * table and select the row at the bottom or top. + */ + int index = -1; + if (key == KeyEvent.VK_UP) { + index = table.getSelectedRow() - 1; + if (index < 0) + index = items - 1; + } else if (key == KeyEvent.VK_DOWN) { + index = table.getSelectedRow() + 1; + if (index >= items) + index = Math.min(items - 1, 0); + } + if (index >= 0) { + table.requestFocus(); + // completions.ensureIndexIsVisible(index); + table.setRowSelectionInterval(index, index); + } + } else if (key == KeyEvent.VK_BACK_SPACE || key == KeyEvent.VK_DELETE) { + /* + * If someone presses backspace or delete they probably want to remove the last + * letter from the search string, so switch the focus back to the prompt: + */ + searchField.requestFocus(); + } else if (source == table) { + /* If you hit enter with the focus in the table, run the selected command */ + if (key == KeyEvent.VK_ENTER) { + ke.consume(); + final int row = table.getSelectedRow(); + if (row >= 0) + runCmd(table.getInternalModel().getCommand(row)); + /* Loop through the list using the arrow keys */ + } else if (key == KeyEvent.VK_UP) { + if (table.getSelectedRow() == 0) + table.setRowSelectionInterval(table.getRowCount() - 1, table.getRowCount() - 1); + } else if (key == KeyEvent.VK_DOWN) { + if (table.getSelectedRow() == table.getRowCount() - 1) + table.setRowSelectionInterval(0, 0); + } + } + } + } + + private class Palette extends JFrame { + private static final long serialVersionUID = 1L; + + Palette() { + super("Command Palette"); + setUndecorated(true); + setAlwaysOnTop(true); + setOpacity(OPACITY); + getRootPane().setWindowDecorationStyle(JRootPane.NONE); + // it should NOT be possible to minimize this frame, but just to + // be safe, we'll ensure the frame is never in an awkward state + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(final WindowEvent e) { + hideWindow(); + } + + @Override + public void windowIconified(final WindowEvent e) { + hideWindow(); + } + + @Override + public void windowDeactivated(final WindowEvent e) { + hideWindow(); + } + }); + } + + void center(final Container component) { + final Rectangle bounds = component.getBounds(); + final Dimension w = getSize(); + int x = bounds.x + (bounds.width - w.width) / 2; + int y = bounds.y + (bounds.height - w.height) / 2; + if (x < 0) + x = 0; + if (y < 0) + y = 0; + setLocation(x, y); + } + } + + private class CmdTable extends JTable { + private static final long serialVersionUID = 1L; + + CmdTable() { + super(new CmdTableModel()); + setAutoCreateRowSorter(false); + setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + setShowGrid(false); + setRowSelectionAllowed(true); + setColumnSelectionAllowed(false); + setTableHeader(null); + setAutoResizeMode(AUTO_RESIZE_LAST_COLUMN); + final CmdTableRenderer renderer = new CmdTableRenderer(); + final int col0Width = renderer.maxWidh(0); + final int col1Width = renderer.maxWidh(1); + setDefaultRenderer(Object.class, renderer); + getColumnModel().getColumn(0).setMaxWidth(col0Width); + getColumnModel().getColumn(1).setMaxWidth(col1Width); + setRowHeight(renderer.rowHeight()); + int height = TABLE_ROWS * getRowHeight(); + if (getRowMargin() > 0) + height *= getRowMargin(); + setPreferredScrollableViewportSize(new Dimension(col0Width + col1Width, height)); + setFillsViewportHeight(true); + } + + private JScrollPane getScrollPane() { + final JScrollPane scrollPane = new JScrollPane(this, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); + scrollPane.setWheelScrollingEnabled(true); + return scrollPane; + } + + CmdTableModel getInternalModel() { + return (CmdTableModel) getModel(); + } + + } + + private class CmdTableRenderer extends DefaultTableCellRenderer { + + private static final long serialVersionUID = 1L; + final Font col0Font = SearchField.REF_FONT.deriveFont(SearchField.REF_FONT.getSize() * 1.2f); + final Font col1Font = SearchField.REF_FONT.deriveFont(SearchField.REF_FONT.getSize() * 1.2f); + + public Component getTableCellRendererComponent(final JTable table, final Object value, final boolean isSelected, + final boolean hasFocus, final int row, final int column) { + final Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + if (column == 1) { + setHorizontalAlignment(JLabel.RIGHT); + setEnabled(false); + setFont(col1Font); + } else { + setHorizontalAlignment(JLabel.LEFT); + setEnabled(true); + setFont(col0Font); + } + return c; + } + + int rowHeight() { + return (int) (col0Font.getSize() * 1.75f); + } + + int maxWidh(final int columnIndex) { + if (columnIndex == 1) + return SwingUtilities.computeStringWidth(getFontMetrics(col1Font), "Really+Huge+Key+Combo"); + return SwingUtilities.computeStringWidth(getFontMetrics(col0Font), + "A large filename from the Recents menu.groovy"); + } + + } + + private class CmdTableModel extends AbstractTableModel { + private static final long serialVersionUID = 1L; + private final static int COLUMNS = 2; + List list; + + void setData(final ArrayList list) { + this.list = list; + fireTableDataChanged(); + } + + String getCommand(final int row) { + if (list.size() == 1) + return (String) getValueAt(row, 0); + else if (row < 0 || row >= list.size()) + return ""; + else + return (String) getValueAt(row, 0); + } + + @Override + public int getColumnCount() { + return COLUMNS; + } + + @Override + public Object getValueAt(final int row, final int column) { + if (row >= list.size() || column >= COLUMNS) + return null; + final String[] strings = (String[]) list.get(row); + return strings[column]; + } + + @Override + public int getRowCount() { + return list.size(); + } + + } + + private class CmdAction { + + final String id; + String menuLocation; + String hotkey; + AbstractButton button; + Action action; + + CmdAction(final String cmdName) { + this.id = capitalize(cmdName); + this.menuLocation = ""; + this.hotkey = ""; + } + + CmdAction(final String cmdName, final AbstractButton button) { + this(cmdName); + if (button.getAction() != null && button.getAction() instanceof AbstractAction) + action = (AbstractAction) button.getAction(); + else + this.button = button; + } + + CmdAction(final String cmdName, final Action action) { + this(cmdName); + this.action = action; + } + + boolean recordable() { + return action != null && action instanceof RecordableTextAction; + } + + String description() { + final String rec = (recordable()) ?" \u29BF" :""; + if (!hotkey.isEmpty()) + return hotkey + rec; + if (!menuLocation.isEmpty()) + return "|" + menuLocation + "|" + rec; + return rec; + } + + boolean matches(final String lowercaseQuery) { + if (IGNORE_WHITESPACE) { + return id.toLowerCase().replaceAll("\\s+", "").contains(lowercaseQuery) + || menuLocation.toLowerCase().contains(lowercaseQuery); + } + return id.toLowerCase().contains(lowercaseQuery) || menuLocation.toLowerCase().contains(lowercaseQuery); + } + + void setkeyString(final KeyStroke key) { + if (hotkey.isEmpty()) { + hotkey = prettifiedKey(key); + } else { + final String oldHotkey = hotkey; + final String newHotKey = prettifiedKey(key); + if (!oldHotkey.contains(newHotKey)) { + hotkey = oldHotkey + " or " + newHotKey; + } + } + } + + private String capitalize(final String string) { + return string.substring(0, 1).toUpperCase() + string.substring(1); + } + + private String prettifiedKey(final KeyStroke key) { + if (key == null) + return ""; + final StringBuilder s = new StringBuilder(); + final int m = key.getModifiers(); + if ((m & InputEvent.CTRL_DOWN_MASK) != 0) { + s.append((PlatformUtils.isMac()) ? "⌃ " : "Ctrl "); + } + if ((m & InputEvent.META_DOWN_MASK) != 0) { + s.append((PlatformUtils.isMac()) ? "⌘ " : "Ctrl "); + } + if ((m & InputEvent.ALT_DOWN_MASK) != 0) { + s.append((PlatformUtils.isMac()) ? "⎇ " : "Alt "); + } + if ((m & InputEvent.SHIFT_DOWN_MASK) != 0) { + s.append("⇧ "); + } + if ((m & InputEvent.BUTTON1_DOWN_MASK) != 0) { + s.append("L-click "); + } + if ((m & InputEvent.BUTTON2_DOWN_MASK) != 0) { + s.append("R-click "); + } + if ((m & InputEvent.BUTTON3_DOWN_MASK) != 0) { + s.append("M-click "); + } + switch (key.getKeyEventType()) { + case KeyEvent.KEY_TYPED: + s.append(key.getKeyChar() + " "); + break; + case KeyEvent.KEY_PRESSED: + case KeyEvent.KEY_RELEASED: + s.append(getKeyText(key.getKeyCode()) + " "); + break; + default: + break; + } + return s.toString(); + } + + String getKeyText(final int keyCode) { + if (keyCode >= KeyEvent.VK_0 && keyCode <= KeyEvent.VK_9 + || keyCode >= KeyEvent.VK_A && keyCode <= KeyEvent.VK_Z) { + return String.valueOf((char) keyCode); + } + switch (keyCode) { + case KeyEvent.VK_COMMA: + return ","; + case KeyEvent.VK_PERIOD: + return "."; + case KeyEvent.VK_SLASH: + return "/"; + case KeyEvent.VK_SEMICOLON: + return ";"; + case KeyEvent.VK_EQUALS: + return "="; + case KeyEvent.VK_OPEN_BRACKET: + return "["; + case KeyEvent.VK_BACK_SLASH: + return "\\"; + case KeyEvent.VK_CLOSE_BRACKET: + return "]"; + case KeyEvent.VK_ENTER: + return "↵"; + case KeyEvent.VK_BACK_SPACE: + return "⌫"; + case KeyEvent.VK_TAB: + return "↹"; + case KeyEvent.VK_CANCEL: + return "Cancel"; + case KeyEvent.VK_CLEAR: + return "Clear"; + case KeyEvent.VK_PAUSE: + return "Pause"; + case KeyEvent.VK_CAPS_LOCK: + return "Caps Lock"; + case KeyEvent.VK_ESCAPE: + return "Esc"; + case KeyEvent.VK_SPACE: + return "Space"; + case KeyEvent.VK_PAGE_UP: + return "⇞"; + case KeyEvent.VK_PAGE_DOWN: + return "⇟"; + case KeyEvent.VK_END: + return "END"; + case KeyEvent.VK_HOME: + return "Home"; // "⌂"; + case KeyEvent.VK_LEFT: + return "←"; + case KeyEvent.VK_UP: + return "↑"; + case KeyEvent.VK_RIGHT: + return "→"; + case KeyEvent.VK_DOWN: + return "↓"; + case KeyEvent.VK_MULTIPLY: + return "[Num ×]"; + case KeyEvent.VK_ADD: + return "[Num +]"; + case KeyEvent.VK_SUBTRACT: + return "[Num −]"; + case KeyEvent.VK_DIVIDE: + return "[Num /]"; + case KeyEvent.VK_DELETE: + return "⌦"; + case KeyEvent.VK_INSERT: + return "Ins"; + case KeyEvent.VK_BACK_QUOTE: + return "`"; + case KeyEvent.VK_QUOTE: + return "'"; + case KeyEvent.VK_AMPERSAND: + return "&"; + case KeyEvent.VK_ASTERISK: + return "*"; + case KeyEvent.VK_QUOTEDBL: + return "\""; + case KeyEvent.VK_LESS: + return "<"; + case KeyEvent.VK_GREATER: + return ">"; + case KeyEvent.VK_BRACELEFT: + return "{"; + case KeyEvent.VK_BRACERIGHT: + return "}"; + case KeyEvent.VK_COLON: + return ","; + case KeyEvent.VK_CIRCUMFLEX: + return "^"; + case KeyEvent.VK_DEAD_TILDE: + return "~"; + case KeyEvent.VK_DOLLAR: + return "$"; + case KeyEvent.VK_EXCLAMATION_MARK: + return "!"; + case KeyEvent.VK_LEFT_PARENTHESIS: + return "("; + case KeyEvent.VK_MINUS: + return "-"; + case KeyEvent.VK_PLUS: + return "+"; + case KeyEvent.VK_RIGHT_PARENTHESIS: + return ")"; + case KeyEvent.VK_UNDERSCORE: + return "_"; + default: + return KeyEvent.getKeyText(keyCode); + } + } + } + + private class CmdScrapper { + final TextEditor textEditor; + static final String REBUILD_ID = "Rebuild Actions Index"; + private final TreeMap cmdMap; + private TreeMap otherMap; + + CmdScrapper(final TextEditor textEditor) { + this.textEditor = textEditor; + // It seems that the ScriptEditor has duplicated actions(!?) registered + // in input/action maps. Some Duplicates seem to be defined in lower + // case, so we'll assemble a case-insensitive map to mitigate this + cmdMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + + TreeMap getCmdMap() { + if (otherMap != null) + cmdMap.putAll(otherMap); + return cmdMap; + } + + boolean scrapeSuccessful() { + return !cmdMap.isEmpty(); + } + + void scrape() { + cmdMap.clear(); + cmdMap.put(REBUILD_ID, new CmdAction(REBUILD_ID)); + parseActionAndInputMaps(); + final JMenuBar menuBar = textEditor.getJMenuBar(); + final int topLevelMenus = menuBar.getMenuCount(); + for (int i = 0; i < topLevelMenus; ++i) { + final JMenu topLevelMenu = menuBar.getMenu(i); + if (topLevelMenu != null && topLevelMenu.getText() != null) { + parseMenu(topLevelMenu.getText(), topLevelMenu); + } + } + final JPopupMenu popup = textEditor.getEditorPane().getPopupMenu(); + if (popup != null) { + getMenuItems(popup).forEach(mi -> { + registerMenuItem(mi, "Popup Menu"); + }); + } + } + + private void parseActionAndInputMaps() { + final InputMap inputMap = textEditor.getTextArea().getInputMap(JComponent.WHEN_FOCUSED); + final KeyStroke[] keys = inputMap.allKeys(); + if (keys != null) { + for (final KeyStroke key : keys) { + if (key.getModifiers() == 0) { + // ignore 'typed' keystrokes and related single-key actions + continue; + } + final Object obj = inputMap.get(key); + CmdAction cmdAction; + String cmdName; + if (obj instanceof Action) { + cmdName = (String) ((Action) obj).getValue(Action.NAME); + cmdAction = new CmdAction(cleanseActionDescription(cmdName), (AbstractAction) obj); + } else if (obj instanceof AbstractButton) { + cmdName = ((AbstractButton) obj).getText(); + cmdAction = new CmdAction(cmdName, (AbstractButton) obj); + } else { + continue; + } + cmdAction.setkeyString(key); + cmdMap.put(cmdAction.id, cmdAction); + } + } + final ActionMap actionMap = textEditor.getTextArea().getActionMap(); + for (final Object obj : actionMap.keys()) { + if (obj instanceof String && cmdMap.get((String) obj) == null) { + final Action action = actionMap.get((String) obj); + final CmdAction cmdAction = new CmdAction(cleanseActionDescription((String) obj), action); + cmdMap.put(cmdAction.id, cmdAction); + } + } + } + + private void parseMenu(final String componentHostingMenu, final JMenu menu) { + final int n = menu.getItemCount(); + for (int i = 0; i < n; ++i) { + registerMenuItem(menu.getItem(i), componentHostingMenu); + } + } + + private void registerMenuItem(final JMenuItem m, final String hostingComponent) { + if (m != null) { + String label = m.getActionCommand(); + if (label == null) + label = m.getText(); + if (m instanceof JMenu) { + final JMenu subMenu = (JMenu) m; + String hostDesc = subMenu.getText(); + if (hostDesc == null) + hostDesc = hostingComponent; + parseMenu(hostDesc, subMenu); + } else { + registerMain(m, hostingComponent); + } + } + } + + private List getMenuItems(final JPopupMenu popupMenu) { + final List list = new ArrayList<>(); + for (final MenuElement me : popupMenu.getSubElements()) { + if (me == null) { + continue; + } else if (me instanceof JMenuItem) { + list.add((JMenuItem) me); + } else if (me instanceof JMenu) { + getMenuItems((JMenu) me, list); + } + } + return list; + } + + private void getMenuItems(final JMenu menu, final List holdingList) { + for (int j = 0; j < menu.getItemCount(); j++) { + final JMenuItem jmi = menu.getItem(j); + if (jmi == null) + continue; + if (jmi instanceof JMenu) { + getMenuItems((JMenu) jmi, holdingList); + } else { + holdingList.add(jmi); + } + } + } + + private boolean irrelevantCommand(final String label) { + // commands that would only add clutter to the palette + return label == null || label.endsWith(" pt") || label.length() < 2; + } + + void registerMain(final AbstractButton button, final String description) { + register(cmdMap, button, description); + } + + void registerOther(final AbstractButton button, final String description) { + if (otherMap == null) + otherMap = new TreeMap<>(); + register(otherMap, button, description); + } + + private void register(final TreeMap map, final AbstractButton button, + final String descriptionOfComponentHostingButton) { + String label = button.getActionCommand(); + if (NAME.equals(label)) + return; // do not register command palette + if (label == null) + label = button.getText().trim(); + if (irrelevantCommand(label)) + return; + if (label.endsWith("...")) + label = label.substring(0, label.length() - 3); + // If a command has already been registered, we'll include its accelerator + final boolean isMenuItem = button instanceof JMenuItem; + final CmdAction registeredAction = (CmdAction) map.get(label); + final KeyStroke accelerator = (isMenuItem) ? ((JMenuItem) button).getAccelerator() : null; + if (registeredAction != null && accelerator != null) { + registeredAction.setkeyString(accelerator); + } else { + final CmdAction ca = new CmdAction(label, button); + ca.menuLocation = descriptionOfComponentHostingButton; + if (accelerator != null) ca.setkeyString(accelerator); + map.put(ca.id, ca); + } + } + + private String cleanseActionDescription(String actionId) { + if (actionId.startsWith("RTA.")) + actionId = actionId.substring(4); + else if (actionId.startsWith("RSTA.")) + actionId = actionId.substring(5); + if (actionId.endsWith("Action")) + actionId = actionId.substring(0, actionId.length() - 6); + actionId = actionId.replace("-", " "); + return actionId.replaceAll("([A-Z])", " $1").trim(); // CamelCase to Camel Case + } + + } + + private class SearchWebCmd extends CmdAction { + SearchWebCmd() { + super("Search the Web"); + button = new JMenuItem(new AbstractAction(id) { + private static final long serialVersionUID = 1L; + + @Override + public void actionPerformed(final ActionEvent e) { + TextEditor.GuiUtils.runSearchQueryInBrowser(textEditor, textEditor.getPlatformService(), + searchField.getText()); + } + }); + } + + @Override + String description() { + return "|Unmatched action|"; + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/DefaultAutoImporters.java b/src/main/java/org/scijava/ui/swing/script/DefaultAutoImporters.java index 64bafcdc..150304de 100644 --- a/src/main/java/org/scijava/ui/swing/script/DefaultAutoImporters.java +++ b/src/main/java/org/scijava/ui/swing/script/DefaultAutoImporters.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/DefaultLanguageSupportService.java b/src/main/java/org/scijava/ui/swing/script/DefaultLanguageSupportService.java index ff067824..98e19fc2 100644 --- a/src/main/java/org/scijava/ui/swing/script/DefaultLanguageSupportService.java +++ b/src/main/java/org/scijava/ui/swing/script/DefaultLanguageSupportService.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/EditorPane.java b/src/main/java/org/scijava/ui/swing/script/EditorPane.java index c8a01ec0..14b86560 100644 --- a/src/main/java/org/scijava/ui/swing/script/EditorPane.java +++ b/src/main/java/org/scijava/ui/swing/script/EditorPane.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -29,11 +29,14 @@ package org.scijava.ui.swing.script; +import java.awt.Color; import java.awt.Component; import java.awt.Container; import java.awt.Dimension; import java.awt.Font; import java.awt.event.ActionEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; @@ -42,36 +45,53 @@ import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; +import java.net.URL; +import java.util.Arrays; import java.util.Collection; import java.util.List; - +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.swing.Action; +import javax.swing.ButtonGroup; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; import javax.swing.JScrollPane; import javax.swing.JViewport; +import javax.swing.MenuElement; import javax.swing.ToolTipManager; +import javax.swing.UIManager; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; +import javax.swing.event.HyperlinkEvent; +import javax.swing.event.HyperlinkListener; import javax.swing.text.BadLocationException; -import javax.swing.text.DefaultEditorKit; import org.fife.rsta.ac.LanguageSupport; +import org.fife.rsta.ac.LanguageSupportFactory; import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.fife.ui.rsyntaxtextarea.Style; import org.fife.ui.rsyntaxtextarea.SyntaxScheme; +import org.fife.ui.rsyntaxtextarea.Theme; +import org.fife.ui.rsyntaxtextarea.parser.TaskTagParser; import org.fife.ui.rtextarea.Gutter; import org.fife.ui.rtextarea.GutterIconInfo; -import org.fife.ui.rtextarea.IconGroup; import org.fife.ui.rtextarea.RTextArea; import org.fife.ui.rtextarea.RTextScrollPane; import org.fife.ui.rtextarea.RecordableTextAction; +import org.fife.ui.rtextarea.SearchContext; +import org.fife.ui.rtextarea.SearchEngine; import org.scijava.Context; import org.scijava.log.LogService; +import org.scijava.platform.PlatformService; import org.scijava.plugin.Parameter; import org.scijava.prefs.PrefService; import org.scijava.script.ScriptHeaderService; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; -import org.scijava.ui.swing.script.autocompletion.JythonAutoCompletion; import org.scijava.util.FileUtils; /** @@ -80,6 +100,7 @@ * * @author Johannes Schindelin * @author Jonathan Hale + * @author Tiago Ferreira */ public class EditorPane extends RSyntaxTextArea implements DocumentListener { @@ -89,11 +110,18 @@ public class EditorPane extends RSyntaxTextArea implements DocumentListener { private long fileLastModified; private ScriptLanguage currentLanguage; private Gutter gutter; - private IconGroup iconGroup; private int modifyCount; private boolean undoInProgress; private boolean redoInProgress; + private boolean autoCompletionEnabled; + private boolean autoCompletionJavaFallback; + private boolean autoCompletionWithoutKey; + private String supportStatus; + private final ErrorParser errorHighlighter; + private final JMenu noneLangSyntaxMenu; + private final EditorPaneActions actions; + @Parameter Context context; @@ -106,27 +134,226 @@ public class EditorPane extends RSyntaxTextArea implements DocumentListener { @Parameter private PrefService prefService; @Parameter + private PlatformService platformService; + @Parameter private LogService log; - - private JythonAutoCompletion autoCompletionProxy; /** * Constructor. */ public EditorPane() { - setLineWrap(false); - setTabSize(8); - - getActionMap() - .put(DefaultEditorKit.nextWordAction, wordMovement(+1, false)); - getActionMap().put(DefaultEditorKit.selectionNextWordAction, - wordMovement(+1, true)); - getActionMap().put(DefaultEditorKit.previousWordAction, - wordMovement(-1, false)); - getActionMap().put(DefaultEditorKit.selectionPreviousWordAction, - wordMovement(-1, true)); + + errorHighlighter= new ErrorParser(this); + actions = new EditorPaneActions(this); + + // set sensible defaults + setAntiAliasingEnabled(true); + setAutoIndentEnabled(true); + setBracketMatchingEnabled(true); + setCloseCurlyBraces(true); + setCloseMarkupTags(true); + setCodeFoldingEnabled(true); + setShowMatchedBracketPopup(true); + setClearWhitespaceLinesEnabled(false); // most folks wont't want this set? + // If a URL exists in commentaries this allows opening it using ctrl+click + setHyperlinksEnabled(true); + addHyperlinkListener(new HyperlinkListener() { + + @Override + public void hyperlinkUpdate(final HyperlinkEvent hle) { + if (HyperlinkEvent.EventType.ACTIVATED.equals(hle.getEventType())) { + try { + platformService.open(hle.getURL()); + } + catch (final IOException exc) { + //ignored + } + } + } + }); + // Add support for TODO, FIXME, HACK + addParser(new TaskTagParser()); + + //NB: Loading of preferences will happen by calling #loadPreferences(); + + // Register recordable actions: TODO this should go to EditorPaneActions + getActionMap().put(EditorPaneActions.nextWordAction, wordMovement("Next-Word-Action", +1, false)); + getActionMap().put(EditorPaneActions.selectionNextWordAction, wordMovement("Next-Word-Select-Action", +1, true)); + getActionMap().put(EditorPaneActions.previousWordAction, wordMovement("Prev-Word-Action", -1, false)); + getActionMap().put(EditorPaneActions.selectionPreviousWordAction, + wordMovement("Prev-Word-Select-Action", -1, true)); + + noneLangSyntaxMenu = geSyntaxForNoneLang(); + installCustomPopupMenu(); + ToolTipManager.sharedInstance().registerComponent(this); - getDocument().addDocumentListener(this); + addMouseListener(new MouseAdapter() { + + SearchContext context; + + @Override + public void mousePressed(final MouseEvent me) { + + // 2022.02 TF: 'Mark All' occurrences is quite awkward. What is + // marked is language-specific and the defaults are restricted + // to certain identifiers. We'll hack things so that it works + // for any selection by double-click. See + // https://github.com/bobbylight/RSyntaxTextArea/issues/88 + if (getMarkOccurrences() && 2 == me.getClickCount()) { + + // Do nothing if getMarkOccurrences() is unset or no valid + // selection exists (we'll skip white space selection) + final String str = getSelectedText(); + if (str == null || str.trim().isEmpty()) return; + + if (context != null && str.equals(context.getSearchFor())) { + // Selection is the previously 'marked all' scope. Clear it + SearchEngine.markAll(EditorPane.this, new SearchContext()); + context = null; + } else { + // Use SearchEngine for 'mark all' + final Color stashedColor = getMarkAllHighlightColor(); + setMarkAllHighlightColor(getMarkOccurrencesColor()); + context = new SearchContext(str, true); + context.setMarkAll(true); + context.setWholeWord(true); + SearchEngine.markAll(EditorPane.this, context); + setMarkAllHighlightColor(stashedColor); + } + } + } + }); + } + + protected boolean isLocked() { + return !(isEditable() && isEnabled()); + } + + @Override + public void addNotify() { + // this seems to solve these issues reported here: + // https://forum.image.sc/t/shiny-new-script-editor/64160/19 + if (isVisible()) super.addNotify(); + } + + private void installCustomPopupMenu() { + // We are overriding the entire menu so that we can include our shortcuts + // in the menu items. These commands are not listed on the menubar, so + // this is the only access point in the GUI for these + // see #createPopupMenu(); #appendFoldingMenu(); + final JPopupMenu popup = new JPopupMenu(); + TextEditor.GuiUtils.addPopupMenuSeparator(popup, "Code Editing:"); + popup.add(getMenuItem("Copy", EditorPaneActions.copyAction, false)); + popup.add(getMenuItem("Cut", EditorPaneActions.cutAction, true)); + popup.add(getMenuItem("Paste", EditorPaneActions.pasteAction, true)); + popup.add(getMenuItem("Delete Line", EditorPaneActions.rtaDeleteLineAction, true)); + popup.add(getMenuItem("Delete Rest of Line", EditorPaneActions.rtaDeleteRestOfLineAction, true)); + TextEditor.GuiUtils.addPopupMenuSeparator(popup, "Code Selection:"); + popup.add(getMenuItem("Select All", EditorPaneActions.selectAllAction, false)); + popup.add(getMenuItem("Select Line", EditorPaneActions.selectLineAction, false)); + popup.add(getMenuItem("Select Paragraph", EditorPaneActions.selectParagraphAction, false)); + TextEditor.GuiUtils.addPopupMenuSeparator(popup, "Code Folding:"); + popup.add(getMenuItem("Collapse Fold", EditorPaneActions.rstaCollapseFoldAction, false)); + popup.add(getMenuItem("Expand Fold", EditorPaneActions.rstaExpandFoldAction, false)); + popup.add(getMenuItem("Toggle Current Fold", EditorPaneActions.rstaToggleCurrentFoldAction, false)); + popup.add(getMenuItem("Collapse All Folds", EditorPaneActions.rstaCollapseAllFoldsAction, false)); + popup.add(getMenuItem("Expand All Folds", EditorPaneActions.rstaExpandAllFoldsAction, false)); + popup.add(getMenuItem("Collapse All Comments", EditorPaneActions.rstaCollapseAllCommentFoldsAction, false)); + TextEditor.GuiUtils.addPopupMenuSeparator(popup, "Code Formatting:"); + popup.add(getMenuItem("Indent Right", EditorPaneActions.epaIncreaseIndentAction, true)); + popup.add(getMenuItem("Indent Left", EditorPaneActions.rstaDecreaseIndentAction, true)); + popup.add(getMenuItem("Move Up", EditorPaneActions.rtaLineUpAction, true)); + popup.add(getMenuItem("Move Down", EditorPaneActions.rtaLineDownAction, true)); + popup.add(getMenuItem("Join Lines", EditorPaneActions.rtaJoinLinesAction, true)); + JMenu menu = new JMenu("Transform Case"); + popup.add(menu); + menu.add(getMenuItem("Invert Case", EditorPaneActions.rtaInvertSelectionCaseAction, true)); + menu.addSeparator(); + menu.add(getMenuItem("Camel Case", EditorPaneActions.epaCamelCaseAction, true)); + menu.add(getMenuItem("Lower Case", EditorPaneActions.rtaLowerSelectionCaseAction, true)); + menu.add(getMenuItem("Lower Case ('_' Sep.)", EditorPaneActions.epaLowerCaseUndAction, true)); + menu.add(getMenuItem("Title Case", EditorPaneActions.epaTitleCaseAction, true)); + menu.add(getMenuItem("Upper Case", EditorPaneActions.rtaUpperSelectionCaseAction, true)); + TextEditor.GuiUtils.addPopupMenuSeparator(popup, "Ocurrences:"); + popup.add(getMenuItem("Next Occurrence", EditorPaneActions.rtaNextOccurrenceAction, false)); + popup.add(getMenuItem("Previous Occurrence", EditorPaneActions.rtaPrevOccurrenceAction, false)); + TextEditor.GuiUtils.addPopupMenuSeparator(popup, "Utilities:"); + popup.add(new OpenLinkUnderCursor().getMenuItem()); + popup.add(new SearchWebOnSelectedText().getMenuItem()); + popup.add(noneLangSyntaxMenu); + super.setPopupMenu(popup); + } + + private JMenuItem getMenuItem(final String label, final String actionID, final boolean editingAction) { + final Action action = getActionMap().get(actionID); + final JMenuItem jmi = new JMenuItem(action); + jmi.setAccelerator(getPaneActions().getAccelerator(actionID)); + jmi.setText(label); + return jmi; + } + + private JMenu geSyntaxForNoneLang() { + final JMenu menu = new JMenu("Non-Executable Syntax"); + menu.setToolTipText("Markup languages when scripting language is none"); + final ButtonGroup bg = new ButtonGroup(); + menu.add(getSyntaxItem(bg, "None", SYNTAX_STYLE_NONE)); + bg.getElements().nextElement().setSelected(true); //select none + menu.addSeparator(); + menu.add(getSyntaxItem(bg, "BAT", SYNTAX_STYLE_WINDOWS_BATCH)); + menu.add(getSyntaxItem(bg, "CSS", SYNTAX_STYLE_CSS)); + menu.add(getSyntaxItem(bg, "Dockerfile", SYNTAX_STYLE_DOCKERFILE)); + menu.add(getSyntaxItem(bg, "HTML", SYNTAX_STYLE_HTML)); + menu.add(getSyntaxItem(bg, "JSON", SYNTAX_STYLE_JSON)); + menu.add(getSyntaxItem(bg, "Kotlin", SYNTAX_STYLE_KOTLIN)); + menu.add(getSyntaxItem(bg, "Makefile", SYNTAX_STYLE_MAKEFILE)); + menu.add(getSyntaxItem(bg, "Markdown", SYNTAX_STYLE_MARKDOWN)); + menu.add(getSyntaxItem(bg, "SH", SYNTAX_STYLE_UNIX_SHELL)); + menu.add(getSyntaxItem(bg, "XML", SYNTAX_STYLE_XML)); + menu.add(getSyntaxItem(bg, "YAML", SYNTAX_STYLE_YAML)); + return menu; + } + + private JMenuItem getSyntaxItem(final ButtonGroup bg, final String label, final String syntaxId) { + final JRadioButtonMenuItem item = new JRadioButtonMenuItem(label); + bg.add(item); + item.addActionListener(e -> { + if (getCurrentLanguage() == null) { + setSyntaxEditingStyle(syntaxId); + } else { + error("Non-executable syntaxes can only be applied when scripting language is 'None'."); + bg.getElements().nextElement().setSelected(true); //select none + setSyntaxEditingStyle(SYNTAX_STYLE_NONE); + } + }); + return item; + } + + void error(final String message) { + TextEditor.GuiUtils.error(this, message); + } + + private void updateNoneLangSyntaxMenu(final ScriptLanguage language) { + noneLangSyntaxMenu.setEnabled(language == null); + if (language == null) { + JRadioButtonMenuItem defaultChoice = null; + try { + for (final MenuElement me : noneLangSyntaxMenu.getSubElements()) { + if (me instanceof JRadioButtonMenuItem) { + final JRadioButtonMenuItem rb = ((JRadioButtonMenuItem) me); + final String choice = rb.getActionCommand(); + if (getSyntaxEditingStyle().equals(choice)) { + rb.setSelected(true); + return; + } + if (SYNTAX_STYLE_NONE.equals(choice)) + defaultChoice = rb; + } + } + defaultChoice.setSelected(true); + } catch (final Exception ignored) { + // do nothing + } + } } @Override @@ -150,25 +377,29 @@ public RTextScrollPane wrappedInScrollbars() { final RTextScrollPane sp = new RTextScrollPane(this); sp.setPreferredSize(new Dimension(600, 350)); sp.setIconRowHeaderEnabled(true); - gutter = sp.getGutter(); - iconGroup = new IconGroup("bullets", "images/", null, "png", null); - gutter.setBookmarkIcon(iconGroup.getIcon("var")); gutter.setBookmarkingEnabled(true); - + gutter.setShowCollapsedRegionToolTips(true); + gutter.setFoldIndicatorEnabled(true); + GutterUtils.updateIcons(gutter); return sp; } - /** - * TODO - * - * @param direction - * @param select - * @return - */ - RecordableTextAction wordMovement(final int direction, final boolean select) { - final String id = "WORD_MOVEMENT_" + select + direction; + RecordableTextAction wordMovement(final String id, final int direction, final boolean select) { return new RecordableTextAction(id) { + private static final long serialVersionUID = 1L; + + @Override + public String getDescription() { + final StringBuilder sb = new StringBuilder(); + if (direction > 0) + sb.append("Next"); + else + sb.append("Previous"); + sb.append("Word"); + if (select) sb.append("Select"); + return sb.toString(); + } @Override public void actionPerformedImpl(final ActionEvent e, @@ -305,41 +536,54 @@ public void write(final File file) throws IOException { public void open(final File file) throws IOException { final File oldFile = curFile; curFile = null; - if (file == null) setText(""); + if (file == null) + setText(""); else { int line = 0; try { - if (file.getCanonicalPath().equals(oldFile.getCanonicalPath())) line = - getCaretLineNumber(); - } - catch (final Exception e) { /* ignore */} + if (file.getCanonicalPath().equals(oldFile.getCanonicalPath())) + line = getCaretLineNumber(); + } catch (final Exception e) { + /* ignore */} if (!file.exists()) { modifyCount = Integer.MIN_VALUE; setFileName(file); - return; + return; + } + final StringBuffer string = new StringBuffer(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + final char[] buffer = new char[16384]; + for (;;) { + final int count = reader.read(buffer); + if (count < 0) + break; + string.append(buffer, 0, count); + } + reader.close(); } - final StringBuffer string = new StringBuffer(); - final BufferedReader reader = - new BufferedReader(new InputStreamReader(new FileInputStream(file), - "UTF-8")); - final char[] buffer = new char[16384]; - for (;;) { - final int count = reader.read(buffer); - if (count < 0) break; - string.append(buffer, 0, count); + try { + setText(string.toString()); + } catch (final Error | IndexOutOfBoundsException e2) { + // Mysterious parsing errors w/ IJM!? Syntax highlighting will + // fail but things should be back to normal on next repaint. See + // https://github.com/scijava/script-editor/issues/14 + // https://forum.image.sc/t/shiny-new-script-editor/64160/19 + log.debug(e2); } - reader.close(); - setText(string.toString()); curFile = file; - if (line > getLineCount()) line = getLineCount() - 1; + if (line > getLineCount()) + line = getLineCount() - 1; try { setCaretPosition(getLineStartOffset(line)); + } catch (final BadLocationException e) { + /* ignore */ + } } - catch (final BadLocationException e) { /* ignore */} - } - discardAllEdits(); - modifyCount = 0; - fileLastModified = file == null || !file.exists() ? 0 : file.lastModified(); + discardAllEdits(); + fileLastModified = file == null || !file.exists() ? 0 : file.lastModified(); + modifyCount = 0; + getDocument().addDocumentListener(this); // Add as late as possible to avoid spurious updates } /** @@ -414,9 +658,13 @@ protected String getFileName() { extension = "." + extensions.get(0); } if (currentLanguage.getLanguageName().equals("Java")) { - final String name = new TokenFunctions(this).getClassName(); - if (name != null) { - return name + extension; + try { + final String name = new TokenFunctions(this).getClassName(); + if (name != null) { + return name + extension; + } + } catch (final Exception ignored) { + // Do nothing } } } @@ -501,9 +749,10 @@ protected void setLanguage(final ScriptLanguage language, try { setSyntaxEditingStyle(styleName); } - catch (final NullPointerException exc) { - // NB: Avoid possible NPEs in RSyntaxTextArea code. + catch (final NullPointerException | IndexOutOfBoundsException exc) { + // NB Avoid possible NPEs and other exceptions in RSyntaxTextArea code. // See: https://fiji.sc/bug/1181.html + // See: https://forum.image.sc/t/shiny-new-script-editor/64160/19 log.debug(exc); } @@ -512,18 +761,89 @@ protected void setLanguage(final ScriptLanguage language, setText(header += getText()); } + updateNoneLangSyntaxMenu(language); + if ("None".equals(languageName) ) { + supportStatus = "Active language: None"; + return; // no need to update console any further + } + String supportLevel = "SciJava supported"; // try to get language support for current language, may be null. support = languageSupportService.getLanguageSupport(currentLanguage); - if (support != null && autoCompletionEnabled) { + // that did not work. See if there is internal support for it. + if (support == null) { + support = LanguageSupportFactory.get().getSupportFor(styleName); + supportLevel = "Legacy supported"; + } + // that did not work, Fallback to Java + if (support == null && autoCompletionJavaFallback) { + support = languageSupportService.getLanguageSupport(scriptService.getLanguageByName("Java")); + supportLevel = "N/A. Using Java as fallback"; + } + if (support != null) { + support.setAutoCompleteEnabled(autoCompletionEnabled); + support.setAutoActivationEnabled(autoCompletionWithoutKey); support.install(this); + if (!autoCompletionEnabled) + supportLevel += " but currently disabled\n"; + else { + supportLevel += " triggered by Ctrl+Space"; + if (autoCompletionWithoutKey) + supportLevel += " & auto-display "; + supportLevel += "\n"; + } + } else { + supportLevel = "N/A"; } + supportStatus = "Active language: " + languageName + "\nAutocompletion: " + supportLevel; + } + + /** + * Toggles whether auto-completion is enabled. + * + * @param enabled Whether auto-activation is enabled. + */ + public void setAutoCompletion(final boolean enabled) { + autoCompletionEnabled = enabled; + if (currentLanguage != null) + setLanguage(currentLanguage); + } + + /** + * Toggles whether auto-completion should adopt Java completions if the current + * language does not support auto-completion. + * + * @param enabled Whether Java should be enabled as fallback language for + * auto-completion + */ + void setFallbackAutoCompletion(final boolean value) { + autoCompletionJavaFallback = value; + if (autoCompletionEnabled && currentLanguage != null) + setLanguage(currentLanguage); + } + + /** + * Toggles whether auto-activation of auto-completion is enabled. Ignored if + * auto-completion is not enabled. + * + * @param enabled Whether auto-activation is enabled. + */ + void setKeylessAutoCompletion(final boolean enabled) { + autoCompletionWithoutKey = enabled; + if (autoCompletionEnabled && currentLanguage != null) + setLanguage(currentLanguage); + } + + public boolean isAutoCompletionEnabled() { + return autoCompletionEnabled; } - private boolean autoCompletionEnabled = true; - public void setAutoCompletionEnabled(boolean value) { - autoCompletionEnabled = value; - setLanguage(currentLanguage); + public boolean isAutoCompletionKeyless() { + return autoCompletionWithoutKey; + } + + public boolean isAutoCompletionFallbackEnabled() { + return autoCompletionJavaFallback; } /** @@ -578,6 +898,12 @@ public void increaseFontSize(final float factor) { final float size = Math.max(5, font.getSize2D() * factor); setFont(font.deriveFont(size)); setSyntaxScheme(scheme); + // Adjust gutter size + if (gutter != null) { + final float lnSize = size * 0.8f; + gutter.setLineNumberFont(font.deriveFont(lnSize)); + GutterUtils.updateIcons(gutter); + } Component parent = getParent(); if (parent instanceof JViewport) { parent = parent.getParent(); @@ -614,7 +940,7 @@ public void toggleBookmark(final int line) { } catch (final BadLocationException e) { /* ignore */ - log.error("Cannot toggle bookmark at this location."); + error("Cannot toggle bookmark at this location."); } } } @@ -712,34 +1038,100 @@ public void convertSpacesToTabs() { public static final String LINE_WRAP_PREFS = "script.editor.WrapLines"; public static final String TAB_SIZE_PREFS = "script.editor.TabSize"; public static final String TABS_EMULATED_PREFS = "script.editor.TabsEmulated"; + public static final String WHITESPACE_VISIBLE_PREFS = "script.editor.Whitespace"; + public static final String TABLINES_VISIBLE_PREFS = "script.editor.Tablines"; + public static final String MARGIN_VISIBLE_PREFS = "script.editor.Margin"; + public static final String THEME_PREFS = "script.editor.theme"; + public static final String AUTOCOMPLETE_PREFS = "script.editor.AC"; + public static final String AUTOCOMPLETE_KEYLESS_PREFS = "script.editor.ACNoKey"; + public static final String AUTOCOMPLETE_FALLBACK_PREFS = "script.editor.ACFallback"; + public static final String MARK_OCCURRENCES_PREFS = "script.editor.Occurrences"; public static final String FOLDERS_PREFS = "script.editor.folders"; - public static final int DEFAULT_TAB_SIZE = 4; + public static final String DEFAULT_THEME = "default"; /** - * Loads the preferences for the Tab and apply them. + * Loads and applies the preferences for the tab */ public void loadPreferences() { - resetTabSize(); - setFontSize(prefService.getFloat(getClass(), FONT_SIZE_PREFS, getFontSize())); - setLineWrap(prefService.getBoolean(getClass(), LINE_WRAP_PREFS, getLineWrap())); - setTabsEmulated(prefService.getBoolean(getClass(), TABS_EMULATED_PREFS, - getTabsEmulated())); + if (prefService == null) { + setLineWrap(false); + setTabSize(DEFAULT_TAB_SIZE); + setLineWrap(false); + setTabsEmulated(false); + setPaintTabLines(false); + setAutoCompletion(true); + setKeylessAutoCompletion(true); // true for backwards compatibility with ImageJ macro auto-completion + setFallbackAutoCompletion(false); + setMarkOccurrences(false); + } else { + resetTabSize(); + setFontSize(prefService.getFloat(getClass(), FONT_SIZE_PREFS, getFontSize())); + setLineWrap(prefService.getBoolean(getClass(), LINE_WRAP_PREFS, getLineWrap())); + setTabsEmulated(prefService.getBoolean(getClass(), TABS_EMULATED_PREFS, getTabsEmulated())); + setWhitespaceVisible(prefService.getBoolean(getClass(), WHITESPACE_VISIBLE_PREFS, isWhitespaceVisible())); + setPaintTabLines(prefService.getBoolean(getClass(), TABLINES_VISIBLE_PREFS, getPaintTabLines())); + setAutoCompletion(prefService.getBoolean(getClass(), AUTOCOMPLETE_PREFS, true)); + setKeylessAutoCompletion(prefService.getBoolean(getClass(), AUTOCOMPLETE_KEYLESS_PREFS, true)); // true for backwards compatibility with ImageJ macro + setFallbackAutoCompletion(prefService.getBoolean(getClass(), AUTOCOMPLETE_FALLBACK_PREFS, false)); + setMarkOccurrences(prefService.getBoolean(getClass(), MARK_OCCURRENCES_PREFS, false)); + setMarginLineEnabled(prefService.getBoolean(getClass(), MARGIN_VISIBLE_PREFS, false)); + applyTheme(themeName()); + } } - + + /** + * Applies a theme to this pane. + * + * @param theme either "default", "dark", "druid", "eclipse", "idea", "monokai", + * "vs" + * @throws IllegalArgumentException If {@code theme} is not a valid option, or + * the resource could not be loaded + */ + public void applyTheme(final String theme) throws IllegalArgumentException { + try { + applyTheme(TextEditor.getTheme(theme)); + } catch (final Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + public String themeName() { + return prefService.get(getClass(), THEME_PREFS, DEFAULT_THEME); + } + + private void applyTheme(final Theme th) throws IllegalArgumentException { + // themes include font size, so we'll need to reset that + final float existingFontSize = getFontSize(); + th.apply(this); + setFontSize(existingFontSize); + GutterUtils.updateIcons(gutter); + } + public String loadFolders() { return prefService.get(getClass(), FOLDERS_PREFS, System.getProperty("user.home")); } /** - * Retrieves and saves the preferences to the persistent store + * Retrieves and saves the preferences to the persistent store. + * + * @param top_folders the File Explorer's pane top folder paths (":" separated list) + * @param theme the Script Editor's theme */ - public void savePreferences(final String top_folders) { + public void savePreferences(final String top_folders, final String theme) { prefService.put(getClass(), TAB_SIZE_PREFS, getTabSize()); prefService.put(getClass(), FONT_SIZE_PREFS, getFontSize()); prefService.put(getClass(), LINE_WRAP_PREFS, getLineWrap()); prefService.put(getClass(), TABS_EMULATED_PREFS, getTabsEmulated()); + prefService.put(getClass(), WHITESPACE_VISIBLE_PREFS, isWhitespaceVisible()); + prefService.put(getClass(), TABLINES_VISIBLE_PREFS, getPaintTabLines()); + prefService.put(getClass(), AUTOCOMPLETE_PREFS, isAutoCompletionEnabled()); + prefService.put(getClass(), AUTOCOMPLETE_KEYLESS_PREFS, isAutoCompletionKeyless()); + prefService.put(getClass(), AUTOCOMPLETE_FALLBACK_PREFS, isAutoCompletionFallbackEnabled()); + prefService.put(getClass(), MARGIN_VISIBLE_PREFS, isMarginLineEnabled()); + prefService.put(getClass(), MARK_OCCURRENCES_PREFS, getMarkOccurrences()); if (null != top_folders) prefService.put(getClass(), FOLDERS_PREFS, top_folders); + if (null != theme) prefService.put(getClass(), THEME_PREFS, theme); } /** @@ -749,4 +1141,142 @@ public void resetTabSize() { setTabSize(prefService.getInt(getClass(), TAB_SIZE_PREFS, DEFAULT_TAB_SIZE)); } + String getSupportStatus() { + return supportStatus; + } + + void openLinkInBrowser(String link) { + try { + if (!link.startsWith("http")) + link = "https://" + link; // or it won't work + platformService.open(new URL(link)); + } catch (final IOException exc) { + UIManager.getLookAndFeel().provideErrorFeedback(this); + System.out.println(exc.getMessage()); + } + } + + ErrorParser getErrorHighlighter() { + return errorHighlighter; + } + + public EditorPaneActions getPaneActions() { + return actions; + } + + class SearchWebOnSelectedText extends RecordableTextAction { + private static final long serialVersionUID = 1L; + + SearchWebOnSelectedText() { + super("RTA.SearchWebOnSelectedTextAction"); + } + + @Override + public void actionPerformedImpl(final ActionEvent e, final RTextArea textArea) { + final String selection = textArea.getSelectedText(); + if (selection == null) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + } else { + TextEditor.GuiUtils.runSearchQueryInBrowser(EditorPane.this, platformService, selection.trim()); + } + textArea.requestFocusInWindow(); + } + + @Override + public String getMacroID() { + return getName(); + } + + JMenuItem getMenuItem() { + final JMenuItem jmi = new JMenuItem(this); + jmi.setText("Search Web for Selection"); + return jmi; + } + } + + class OpenLinkUnderCursor extends RecordableTextAction { + private static final long serialVersionUID = 1L; + + OpenLinkUnderCursor() { + super("RTA.OpenLinkUnderCursor.Action"); + } + + @Override + public void actionPerformedImpl(final ActionEvent e, final RTextArea textArea) { + String link = new CursorUtils(textArea).getLinkAtCursor(); + if (link == null) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + } else { + openLinkInBrowser(link); + } + textArea.requestFocusInWindow(); + } + + @Override + public String getMacroID() { + return getName(); + } + + JMenuItem getMenuItem() { + final JMenuItem jmi = new JMenuItem(this); + jmi.setText("Open URL Under Cursor"); + return jmi; + } + } + + private static class CursorUtils { + + final List SPACE_SEPARATORS = Arrays.asList(" ", "\t", "\f", "\n", "\r"); + final String URL_REGEX = "^((https?|ftp)://|(www|ftp)\\.)?[a-z0-9-]+(\\.[a-z0-9-]+)+([/?].*)?$"; + final Pattern pattern = Pattern.compile(URL_REGEX); + final RTextArea pane; + + private CursorUtils(RTextArea textArea) { + this.pane = textArea; + } + + String getLineAtCursor() { + final int start = pane.getLineStartOffsetOfCurrentLine(); + final int end = pane.getLineEndOffsetOfCurrentLine(); + try { + return pane.getDocument().getText(start, end - start); + } catch (BadLocationException ignored) { + // do nothing + } + return null; + } + + String getWordAtCursor() { + final String text = getLineAtCursor(); + final int pos = pane.getCaretOffsetFromLineStart(); + final int wordStart = getWordStart(text, pos); + final int wordEnd = getWordEnd(text, pos); + return text.substring(wordStart, wordEnd); + } + + String getLinkAtCursor() { + String text = getWordAtCursor(); + if (text == null) + return null; + final Matcher m = pattern.matcher(text); + return (m.find()) ? m.group() : null; + } + + int getWordStart(final String text, final int location) { + int wordStart = location; + while (wordStart > 0 && !SPACE_SEPARATORS.contains(text.substring(wordStart - 1, wordStart))) { + wordStart--; + } + return wordStart; + } + + int getWordEnd(final String text, final int location) { + int wordEnd = location; + while (wordEnd < text.length() - 1 + && !SPACE_SEPARATORS.contains(text.substring(wordEnd, wordEnd + 1))) { + wordEnd++; + } + return wordEnd; + } + } } diff --git a/src/main/java/org/scijava/ui/swing/script/EditorPaneActions.java b/src/main/java/org/scijava/ui/swing/script/EditorPaneActions.java new file mode 100644 index 00000000..d55de7ef --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/EditorPaneActions.java @@ -0,0 +1,396 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script; + +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; + +import javax.swing.Action; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.KeyStroke; +import javax.swing.UIManager; +import javax.swing.text.BadLocationException; +import javax.swing.text.Caret; +import javax.swing.text.Document; +import javax.swing.text.Element; +import javax.swing.text.Segment; + +import org.fife.ui.rsyntaxtextarea.RSyntaxTextAreaEditorKit; +import org.fife.ui.rsyntaxtextarea.RSyntaxUtilities; +import org.fife.ui.rtextarea.RTextArea; +import org.fife.ui.rtextarea.RecordableTextAction; + + +public class EditorPaneActions extends RSyntaxTextAreaEditorKit { + + private static final long serialVersionUID = 1L; + // action ids starting with rta: see RTextAreaEditorKit + RTADefaultInputMap + // action ids starting with rsta: see RSyntaxTextAreaEditorKit + RSyntaxTextAreaDefaultInputMap + // action ids starting with epa: this class + public static final String epaCamelCaseAction = "RTA.CamelCaseAction"; + public static final String epaLowerCaseUndAction = "RTA.LowerCaseUnderscoreSep.Action"; + public static final String epaIncreaseIndentAction = "RSTA.IncreaseIndentAction"; + public static final String epaTitleCaseAction = "RTA.TitleCaseAction"; + public static final String epaToggleCommentAltAction = "RSTA.ToggleCommentAltAction"; + private final EditorPane editorPane; + + public EditorPaneActions(final EditorPane editorPane) { + super(); + this.editorPane = editorPane; + + final int defaultMod = RTextArea.getDefaultModifier(); + final int shift = InputEvent.SHIFT_DOWN_MASK; + final InputMap map = editorPane.getInputMap(); + + /* + * To override existing keybindings: + * 1. Find the ID of the action in either org.fife.ui.rtextarea.RTADefaultInputMap + * or org.fife.ui.rsyntaxtextarea.RSyntaxTextAreaDefaultInputMap + * 2. Put below the new keystroke and the action ID + */ + + // toggle comments + if (RSyntaxUtilities.OS_LINUX == RSyntaxUtilities.getOS()) { + // See note on RSyntaxTextAreaDefaultInputMap: Ctrl+/ types '/' on linux! + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_SLASH, defaultMod + shift), rstaToggleCommentAction); + } + // ES/DE/PT layout hack: https://forum.image.sc/t/shiny-new-script-editor/64160/11 + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_7, defaultMod), epaToggleCommentAltAction); + + // indentation: default alt+tab/shift+alt+tab combos collide w/ OS shortcuts (at least in linux) + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_I, defaultMod), epaIncreaseIndentAction); + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_I, defaultMod + shift), rstaDecreaseIndentAction); + + // editing: override defaults for undo/redo/copy/cut/paste for consistency with menubar + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, defaultMod), copyAction); + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_V, defaultMod), pasteAction); + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_X, defaultMod), cutAction); + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_Z, defaultMod), rtaUndoAction); + map.put(KeyStroke.getKeyStroke(KeyEvent.VK_Y, defaultMod), rtaRedoAction); // this should be ctrl+shift+z!? + + /* + * see RSyntaxTextAreaDefaultInputMap and RTADefaultInputMap for other bindings. + * Note that some of those bindings must be overridden in map.getParent() + */ + + installCustomActions(); + } + + private void installCustomActions() { + final ActionMap map = editorPane.getActionMap(); + + // actions with alternative shortcuts + map.put(epaToggleCommentAltAction, new ToggleCommentAltAction()); + + // case-related actions + map.put(epaCamelCaseAction, new CamelCaseAction()); + map.put(epaLowerCaseUndAction, new LowerCaseUnderscoreAction()); + map.put(epaTitleCaseAction, new TitleCaseAction()); + if (map.get(rtaInvertSelectionCaseAction) != null) + map.put(rtaInvertSelectionCaseAction, new InvertSelectionCaseAction()); + + // indent-related actions + map.put(epaIncreaseIndentAction, new IncreaseIndentAction()); + if (map.get(rstaDecreaseIndentAction) != null) + map.put(rstaDecreaseIndentAction, new DecreaseIndentAction()); + + // line-related actions + if (map.get(rtaLineUpAction) != null) + map.put(rtaLineUpAction, new LineMoveAction(rtaLineUpAction, -1)); + if (map.get(rtaLineDownAction) != null) + map.put(rtaLineDownAction, new LineMoveAction(rtaLineUpAction, 1)); + + // actions that are not registered by default + map.put(rtaTimeDateAction, new TimeDateAction()); + map.put(clipboardHistoryAction, new ClipboardHistoryActionImpl()); + // NB: This action is present in rsyntaxtextarea 3.1.1, but not 3.1.6. + // So we disable it for the time being. + //if (map.get(rstaCopyAsStyledTextAction) != null) + // map.put(rstaCopyAsStyledTextAction, new CopyAsStyledTextAction()); + if (map.get(rstaGoToMatchingBracketAction) != null) + map.put(rstaGoToMatchingBracketAction, new GoToMatchingBracketAction()); + + } + + public KeyStroke getAccelerator(final String actionID) { + final Action action = editorPane.getActionMap().get(actionID); + if (action == null) return null; + // Pass 1: Current map, this should take precedence + for (final KeyStroke key: editorPane.getInputMap().keys()) { + if (actionID.equals(editorPane.getInputMap().get(key))) { + return key; + } + } + // Pass 2: All mappings, including parent map + for (final KeyStroke key: editorPane.getInputMap().allKeys()) { + if (actionID.equals(editorPane.getInputMap().get(key))) { + return key; + } + } + final KeyStroke[] keyStrokes = editorPane.getKeymap().getKeyStrokesForAction(action); + if (keyStrokes != null && keyStrokes.length > 0) { + return keyStrokes[0]; + } + return (KeyStroke) action.getValue(Action.ACCELERATOR_KEY); + } + + public String getAcceleratorLabel(final String actionID) { + final KeyStroke ks = getAccelerator(actionID); + return (ks == null) ? "" : ks.toString().replace(" pressed ", " ").replace(" ", "+").toUpperCase(); + } + + /* dummy copy of ToggleCommentAction to allow for dual inputMap registration */ + static class ToggleCommentAltAction extends ToggleCommentAction { + private static final long serialVersionUID = 1L; + + ToggleCommentAltAction() { + super(); + setName(epaToggleCommentAltAction); + } + } + + + /* Variant of original action that does not allow pasting if textArea is locked */ + static class ClipboardHistoryActionImpl extends ClipboardHistoryAction { + + private static final long serialVersionUID = 1L; + + @Override + public void actionPerformedImpl(ActionEvent e, RTextArea textArea) { + final boolean editingPossible = textArea.isEditable() && textArea.isEnabled(); + if (!editingPossible) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + return; + } + super.actionPerformedImpl(e, textArea); + } + } + + static class CamelCaseAction extends RecordableTextAction { + private static final long serialVersionUID = 1L; + + CamelCaseAction() { + super(epaCamelCaseAction); + } + + @Override + public void actionPerformedImpl(final ActionEvent e, final RTextArea textArea) { + if (!textArea.isEditable() || !textArea.isEnabled()) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + return; + } + final String selection = textArea.getSelectedText(); + if (selection != null) { + final String[] words = selection.split("[\\W_]+"); + final StringBuilder buffer = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + String word = words[i]; + if (i == 0) { + word = word.isEmpty() ? word : word.toLowerCase(); + } else { + word = word.isEmpty() ? word + : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase(); + } + buffer.append(word); + } + textArea.replaceSelection(buffer.toString()); + } + textArea.requestFocusInWindow(); + } + + @Override + public String getMacroID() { + return epaCamelCaseAction; + } + + } + + static class TitleCaseAction extends RecordableTextAction { + private static final long serialVersionUID = 1L; + + TitleCaseAction() { + super(epaTitleCaseAction); + } + + @Override + public void actionPerformedImpl(final ActionEvent e, final RTextArea textArea) { + if (!textArea.isEditable() || !textArea.isEnabled()) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + return; + } + final String selection = textArea.getSelectedText(); + if (selection != null) { + final String[] words = selection.split("[\\W_]+"); + final StringBuilder buffer = new StringBuilder(); + for (int i = 0; i < words.length; i++) { + String word = words[i]; + word = word.isEmpty() ? word + : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase(); + buffer.append(word); + if (i < words.length - 1) + buffer.append(" "); + } + textArea.replaceSelection(buffer.toString()); + } + textArea.requestFocusInWindow(); + } + + @Override + public String getMacroID() { + return epaTitleCaseAction; + } + + } + + static class LowerCaseUnderscoreAction extends RecordableTextAction { + private static final long serialVersionUID = 1L; + + LowerCaseUnderscoreAction() { + super(epaLowerCaseUndAction); + } + + @Override + public void actionPerformedImpl(final ActionEvent e, final RTextArea textArea) { + if (!textArea.isEditable() || !textArea.isEnabled()) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + return; + } + final String selection = textArea.getSelectedText(); + if (selection != null) + textArea.replaceSelection(selection.trim().replaceAll("\\s", "_").toLowerCase()); + textArea.requestFocusInWindow(); + } + + @Override + public String getMacroID() { + return epaLowerCaseUndAction; + } + + } + + /* Modified from DecreaseIndentAction */ + static class IncreaseIndentAction extends RecordableTextAction { + + private static final long serialVersionUID = 1L; + + private final Segment s; + + public IncreaseIndentAction() { + super(epaIncreaseIndentAction); + s = new Segment(); + } + + @Override + public void actionPerformedImpl(final ActionEvent e, final RTextArea textArea) { + + if (!textArea.isEditable() || !textArea.isEnabled()) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + return; + } + + final Document document = textArea.getDocument(); + final Element map = document.getDefaultRootElement(); + final Caret c = textArea.getCaret(); + int dot = c.getDot(); + int mark = c.getMark(); + int line1 = map.getElementIndex(dot); + final int tabSize = textArea.getTabSize(); + final StringBuilder sb = new StringBuilder(); + if (textArea.getTabsEmulated()) { + while (sb.length() < tabSize) { + sb.append(' '); + } + } else { + sb.append('\t'); + } + final String paddingString = sb.toString(); + + // If there is a selection, indent all lines in the selection. + // Otherwise, indent the line the caret is on. + if (dot != mark) { + final int line2 = map.getElementIndex(mark); + dot = Math.min(line1, line2); + mark = Math.max(line1, line2); + Element elem; + textArea.beginAtomicEdit(); + try { + for (line1 = dot; line1 < mark; line1++) { + elem = map.getElement(line1); + handleIncreaseIndent(elem, document, paddingString); + } + // Don't do the last line if the caret is at its + // beginning. We must call getDot() again and not just + // use 'dot' as the caret's position may have changed + // due to the insertion of the tabs above. + elem = map.getElement(mark); + final int start = elem.getStartOffset(); + if (Math.max(c.getDot(), c.getMark()) != start) { + handleIncreaseIndent(elem, document, paddingString); + } + } catch (final BadLocationException ble) { + ble.printStackTrace(); + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + } finally { + textArea.endAtomicEdit(); + } + } else { + final Element elem = map.getElement(line1); + try { + handleIncreaseIndent(elem, document, paddingString); + } catch (final BadLocationException ble) { + ble.printStackTrace(); + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + } + } + + } + + @Override + public final String getMacroID() { + return epaIncreaseIndentAction; + } + + private void handleIncreaseIndent(final Element elem, final Document doc, final String pad) + throws BadLocationException { + final int start = elem.getStartOffset(); + int end = elem.getEndOffset() - 1; // Why always true?? + doc.getText(start, end - start, s); + final int i = s.offset; + end = i + s.count; + if (end > i || (end == i && i == 0)) { + doc.insertString(start, pad, null); + } + } + + } + +} diff --git a/src/main/java/org/scijava/ui/swing/script/ErrorHandler.java b/src/main/java/org/scijava/ui/swing/script/ErrorHandler.java index 03b2d6cb..aa461062 100644 --- a/src/main/java/org/scijava/ui/swing/script/ErrorHandler.java +++ b/src/main/java/org/scijava/ui/swing/script/ErrorHandler.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/ErrorParser.java b/src/main/java/org/scijava/ui/swing/script/ErrorParser.java new file mode 100644 index 00000000..b978397e --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/ErrorParser.java @@ -0,0 +1,428 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script; + +import java.awt.Color; +import java.awt.Dialog; +import java.awt.Rectangle; +import java.awt.Window; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.Collection; +import java.util.StringTokenizer; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.swing.UIManager; +import javax.swing.text.BadLocationException; +import javax.swing.text.Element; + +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.SyntaxConstants; +import org.fife.ui.rsyntaxtextarea.parser.AbstractParser; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParseResult; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParserNotice; +import org.fife.ui.rsyntaxtextarea.parser.ParseResult; +import org.fife.ui.rsyntaxtextarea.parser.Parser; +import org.scijava.script.ScriptLanguage; + +public class ErrorParser { + + /* Color for ErrorStrip marks and fallback taint for line highlights */ + private static final Color COLOR = Color.RED; + /* 0-base indices of Editor's lines that have errored */ + private TreeSet errorLines; + /* When running selected code errored lines map to Editor through this offset */ + private int lineOffset; + private boolean enabled; + private final EditorPane editorPane; + private JTextAreaWriter writer; + private int lengthOfJTextAreaWriter; + private ErrorStripNotifyingParser notifyingParser; + private boolean parsingSucceeded; + + public ErrorParser(final EditorPane editorPane) { + this.editorPane = editorPane; + lineOffset = 0; + } + + public void gotoPreviousError() { + if (!isCaretMovable()) { + try { + final int caretLine = editorPane.getCaretLineNumber(); + gotoLine(errorLines.lower(caretLine)); + } catch (final NullPointerException ignored) { + gotoLine(errorLines.last()); + } + } + } + + public void gotoNextError() { + if (isCaretMovable()) { + try { + final int caretLine = editorPane.getCaretLineNumber(); + gotoLine(errorLines.higher(caretLine)); + } catch (final NullPointerException ignored) { + gotoLine(errorLines.first()); + } + } + } + + public void reset() { + if (notifyingParser != null) { + editorPane.removeParser(notifyingParser); + if (notifyingParser.highlightAbnoxiously) + editorPane.removeAllLineHighlights(); + } + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public void setLineOffset(final int zeroBasedlineOffset) { + this.lineOffset = zeroBasedlineOffset; + } + + public void setSelectedCodeExecution(final boolean selectedExecution) { + if (selectedExecution) { + final int p0 = Math.min(editorPane.getCaret().getDot(), editorPane.getCaret().getMark()); + final int p1 = Math.max(editorPane.getCaret().getDot(), editorPane.getCaret().getMark()); + if (p0 != p1) { + try { + lineOffset = editorPane.getLineOfOffset(p0); + } catch (final BadLocationException ignored) { + lineOffset = -1; + } + } else { + lineOffset = -1; + } + } else { + lineOffset = 0; + } + } + + public void setWriter(final JTextAreaWriter writer) { + this.writer = writer; + lengthOfJTextAreaWriter = writer.textArea.getText().length(); + } + + public void parse() { + if (writer == null) + throw new IllegalArgumentException("Writer is null"); + parse(writer.textArea.getText().substring(lengthOfJTextAreaWriter)); + } + + public void parse(final Throwable t) { + if (!enabled) + return; + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw); + t.printStackTrace(pw); + parse(sw.toString()); + } + + private boolean isImageJMacro() { + final ScriptLanguage lang = editorPane.getCurrentLanguage(); + if (lang == null) return false; + final String name = lang.getLanguageName(); + return name.equals("ImageJ Macro") || name.equals("IJ1 Macro"); + } + + public boolean isLogDetailed() { + if (!isImageJMacro()) return parsingSucceeded; + for (final Window win : Window.getWindows()) { + if (win != null && win instanceof Dialog && "Macro Error".equals(((Dialog)win).getTitle())) { + return true; // hopefully there is something in the console + } + } + return parsingSucceeded; + } + + private boolean isCaretMovable() { + if (errorLines == null || errorLines.isEmpty()) { + UIManager.getLookAndFeel().provideErrorFeedback(editorPane); + return false; + } + return true; + } + + private void gotoLine(final int lineNumber) { + try { + editorPane.setCaretPosition(editorPane.getLineStartOffset(lineNumber)); + // ensure line is visible. Probably not needed!? + final Rectangle rect = editorPane.modelToView(editorPane.getCaretPosition()); + editorPane.scrollRectToVisible(rect); + } catch (final BadLocationException ignored) { + // do nothing + } finally { + editorPane.requestFocusInWindow(); + } + } + + private void parse(final String errorLog) { + + final ScriptLanguage lang = editorPane.getCurrentLanguage(); + if (lang == null) { + abort(); + return; + } + final boolean isIJM = isImageJMacro(); + if (isIJM) { + abort("Execution errors handled by the Macro Interpreter. Use the Interpreter's Debug option for error tracking", false); + return; + } + // Do nothing if disabled, or if only selected text was evaluated in the + // script but we don't know where in the document such selection occurred + if (!enabled) { + abort("Execution errors are not highlighted when auto-imports are active", true); + return; + } + if (lineOffset == -1) { + abort("Code selection unknown: Erros are not highlighted in the Editor", true); + return; + } + + final boolean isJava = "Java".equals(lang.getLanguageName()); + final String fileName = editorPane.getFileName(); + if (isJava && fileName == null) { + abort(); + return; + } + + // HACK scala code seems to always be pre-pended by some 10 lines of code(!?). + if ("Scala".equals(lang.getLanguageName())) + lineOffset += 10; + // HACK and R by one (!?) + else if ("R".equals(lang.getLanguageName())) + lineOffset += 1; + + errorLines = new TreeSet<>(); + final StringTokenizer tokenizer = new StringTokenizer(errorLog, "\n"); + if (isJava) { + while (tokenizer.hasMoreTokens()) { + parseJava(fileName, tokenizer.nextToken(), errorLines); + } + } else { + while (tokenizer.hasMoreTokens()) { + parseNonJava(tokenizer.nextToken(), errorLines); + } + } + if (!errorLines.isEmpty()) { + notifyingParser = new ErrorStripNotifyingParser(); + editorPane.addParser(notifyingParser); + editorPane.forceReparsing(notifyingParser); + gotoLine(errorLines.first()); + } + parsingSucceeded = true; + } + + private void parseNonJava(final String lineText, final Collection errorLines) { + + if ( // Elimination of some false positives. TODO: Make this Regex + lineText.indexOf(":classloader:") > -1 // ruby + || lineText.indexOf(".org.python.") > -1 // python + || lineText.indexOf(".codehaus.groovy.") > -1 // groovy + || lineText.indexOf(".tools.nsc.") > -1 // scala + || lineText.indexOf("at bsh.") > -1 // beanshel + || lineText.indexOf("$Recompilation$") > -1 // javascript + ) {// + return; + } + + final int extensionIdx = extensionIdx(lineText); + final int lineIdx = lineText.toLowerCase().indexOf("line"); + if (lineIdx < 0 && extensionIdx < 0 && filenameIdx(lineText) < 0) + return; + + extractLineIndicesFromFilteredTextLines(lineText, errorLines); + } + + private void extractLineIndicesFromFilteredTextLines(final String lineText, final Collection errorLines) { +// System.out.println("Section being matched: " + lineText); + final Pattern pattern = Pattern.compile(":(\\d+)|line\\D*(\\d+)", Pattern.CASE_INSENSITIVE); + final Matcher matcher = pattern.matcher(lineText); + + if (matcher.find()) { + try { + final String firstGroup = matcher.group(1); + final String lastGroup = matcher.group(matcher.groupCount()); + final String group = (firstGroup == null) ? lastGroup : firstGroup; +// System.out.println("firstGroup: " + firstGroup); +// System.out.println("lastGroup: " + lastGroup); + + final int lineNumber = Integer.valueOf(group.trim()); + if (lineNumber > 0) + errorLines.add(lineNumber - 1 + lineOffset); // store 0-based indices + } catch (final NumberFormatException e) { + e.printStackTrace(); + } + } + } + + private int extensionIdx(final String line) { + int dotIndex = -1; + for (final String extension : editorPane.getCurrentLanguage().getExtensions()) { + dotIndex = line.indexOf("." + extension); + if (dotIndex > -1) + return dotIndex; + } + return -1; + } + + private int filenameIdx(final String line) { + int index = line.indexOf(editorPane.getFileName()); + if (index == -1) + index = (line.indexOf(" Script")); // unsaved file, etc. + return index; + } + + private void parseJava(final String filename, final String line, final Collection errorLines) { + int colon = line.indexOf(filename); + if (colon <= 0) + return; +// System.out.println("Parsing candidate: " + line); + colon += filename.length(); + final int next = line.indexOf(':', colon + 1); + if (next < colon + 2) + return; + try { + final int lineNumber = Integer.parseInt(line.substring(colon + 1, next)); + if (lineNumber > 0) + errorLines.add(lineNumber - 1 + lineOffset); // store 0-based indices +// System.out.println("line Np: " + (lineNumber - 1)); + } catch (final NumberFormatException e) { + // ignore + } + } + + private void abort() { + parsingSucceeded = false; + } + + private void abort(final String msg, final boolean offsetNotice) { + abort(); + if (writer != null) { + String finalMsg = "[INFO] " + msg + "\n"; + if (offsetNotice) + finalMsg += "[INFO] Reported error line(s) may not match line numbers in the editor\n"; + writer.textArea.insert(finalMsg, lengthOfJTextAreaWriter); + } + errorLines = null; + } + + /** + * This is just so that we can register errorLines in the Editor's + * {@link org.fife.ui.rsyntaxtextarea.ErrorStrip} + */ + class ErrorStripNotifyingParser extends AbstractParser { + + private final DefaultParseResult result; + private final boolean highlightAbnoxiously = true; + + public ErrorStripNotifyingParser() { + result = new DefaultParseResult(this); + } + + @Override + public boolean isEnabled() { + return enabled && errorLines != null; + } + + @Override + public ParseResult parse(final RSyntaxDocument doc, final String style) { + final Element root = doc.getDefaultRootElement(); + final int lineCount = root.getElementCount(); + result.clearNotices(); + result.setParsedLines(0, lineCount - 1); + if (isEnabled() && !SyntaxConstants.SYNTAX_STYLE_NONE.equals(style)) { + errorLines.forEach(line -> { + result.addNotice(new ErrorNotice(this, line)); + }); + if (highlightAbnoxiously) { + final Color c = highlightColor(); + errorLines.forEach(line -> { + try { + editorPane.addLineHighlight(line, c); + } catch (final BadLocationException ignored) { + // do nothing + } + }); + } + } + return result; + + } + + } + private Color highlightColor() { + // https://stackoverflow.com/a/29576746 + final Color c1 = editorPane.getCurrentLineHighlightColor(); + final Color c2 = (editorPane.getBackground() == null) ? COLOR : editorPane.getBackground(); + return averageColors(c1, c2); + } + + protected static Color averageColors(final Color c1, final Color c2) { + // https://stackoverflow.com/a/29576746 + final int r = (int) Math.sqrt( (Math.pow(c1.getRed(), 2) + Math.pow(c2.getRed(), 2)) / 2); + final int g = (int) Math.sqrt( (Math.pow(c1.getGreen(), 2) + Math.pow(c2.getGreen(), 2)) / 2); + final int b = (int) Math.sqrt( (Math.pow(c1.getBlue(), 2) + Math.pow(c2.getGreen(), 2)) / 2); + return new Color(r, g, b, c1.getAlpha()); + } + + class ErrorNotice extends DefaultParserNotice { + public ErrorNotice(final Parser parser, final int line) { + super(parser, "Run Error: Line " + (line + 1), line); + setColor(COLOR); + setLevel(Level.ERROR); + setShowInEditor(true); + } + + } + + public static void main(final String[] args) throws Exception { + // poor man's test for REGEX filtering + final String groovy = " at Script1.run(Script1.groovy:51)"; + final String python = "File \"New_.py\", line 51, in "; + final String ruby = "
at Batch_Convert.rb:51"; + final String scala = " at line number 51 at column number 18"; + final String beanshell = "or class name: Systesm : at Line: 51 : in file: "; + final String javascript = " at jdk.nashorn.internal.scripts.Script$15$Greeting.:program(Greeting.js:51)"; + Arrays.asList(groovy, python, ruby, scala, beanshell, javascript).forEach(lang -> { + final ErrorParser parser = new ErrorParser(new EditorPane()); + final TreeSet errorLines = new TreeSet<>(); + parser.extractLineIndicesFromFilteredTextLines(lang, errorLines); + assert (errorLines.first() == 50); + System.out.println((errorLines.first() == 50) + ": <<" + lang + ">> "); + }); + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/ExceptionHandler.java b/src/main/java/org/scijava/ui/swing/script/ExceptionHandler.java index ca47d2fc..0d3ebed9 100644 --- a/src/main/java/org/scijava/ui/swing/script/ExceptionHandler.java +++ b/src/main/java/org/scijava/ui/swing/script/ExceptionHandler.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/FileDrop.java b/src/main/java/org/scijava/ui/swing/script/FileDrop.java new file mode 100644 index 00000000..738536f4 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/FileDrop.java @@ -0,0 +1,990 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script; + +import java.awt.datatransfer.DataFlavor; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.io.Reader; + +import javax.swing.UIManager; + + +/** + * This class makes it easy to drag and drop files from the operating system to + * a Java program. Any {@code java.awt.Component} can be dropped onto, but only + * {@code javax.swing.JComponent}s will indicate the drop event with a changed + * border. + *

+ * To use this class, construct a new {@code FileDrop} by passing it the target + * component and a {@code Listener} to receive notification when file(s) have + * been dropped. Here is an example: + *

+ * + *
+ *      JPanel myPanel = new JPanel();
+ *      new FileDrop( myPanel, new FileDrop.Listener()
+ *      {   public void filesDropped( java.io.File[] files )
+ *          {   
+ *              // handle file drop
+ *              ...
+ *          }   // end filesDropped
+ *      }); // end FileDrop.Listener
+ * 
+ *

+ * You can specify the border that will appear when files are being dragged by + * calling the constructor with a {@code javax.swing.border.Border}. Only + * {@code JComponent}s will show any indication with a border. + *

+ *

+ * You can turn on some debugging features by passing a {@code PrintStream} + * object (such as {@code System.out}) into the full constructor. A + * {@code null} value will result in no extra debugging information being + * output. + *

+ * + *

+ * I'm releasing this code into the Public Domain. Enjoy. + *

+ *

+ * Original author: Robert Harder, rob@iharder.net + *

+ *

+ * Additional support: + *

+ *
    + *
  • September 2007, Nathan Blomquist -- Linux (KDE/Gnome) support added.
  • + *
  • December 2010, Joshua Gerth
  • + *
  • June 2019, TF, Adjust defaultBorderColor. Code cleanup. Added the ability + * to abort drop operation using Esc + *
+ * + * @author Robert Harder + * @version 1.1.1 + */ +public class FileDrop { + private transient javax.swing.border.Border normalBorder; + private transient java.awt.dnd.DropTargetListener dropListener; + + /** Discover if the running JVM is modern enough to have drag and drop. */ + private static Boolean supportsDnD; + + // Default border color + private static java.awt.Color defaultBorderColor = UIManager.getColor("Tree.selectionBackground"); + static { + if (defaultBorderColor == null) defaultBorderColor = new java.awt.Color(0f,0f, 1f, 0.25f); + } + + /** + * Constructs a {@link FileDrop} with a default light-blue border and, if + * c is a {@link java.awt.Container}, recursively sets all + * elements contained within as drop targets, though only the top level + * container will change borders. + * + * @param c + * Component on which files will be dropped. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + public FileDrop(final java.awt.Component c, final Listener listener) { + this(null, // Logging stream + c, // Drop target + javax.swing.BorderFactory.createMatteBorder(2, 2, 2, 2, + defaultBorderColor), // Drag border + true, // Recursive + listener); + } // end constructor + + /** + * Constructor with a default border and the option to recursively set drop + * targets. If your component is a {@code java.awt.Container}, then each of + * its children components will also listen for drops, though only the + * parent will change borders. + * + * @param c + * Component on which files will be dropped. + * @param recursive + * Recursively set children as drop targets. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.awt.Component c, final boolean recursive, + final Listener listener) { + this(null, // Logging stream + c, // Drop target + javax.swing.BorderFactory.createMatteBorder(2, 2, 2, 2, + defaultBorderColor), // Drag border + recursive, // Recursive + listener); + } // end constructor + + /** + * Constructor with a default border and debugging optionally turned on. + * With Debugging turned on, more status messages will be displayed to + * {@code out}. A common way to use this constructor is with + * {@code System.out} or {@code System.err}. A {@code null} value for the + * parameter {@code out} will result in no debugging output. + * + * @param out + * PrintStream to record debugging info or null for no debugging. + * @param c + * Component on which files will be dropped. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.io.PrintStream out, final java.awt.Component c, + final Listener listener) { + this(out, // Logging stream + c, // Drop target + javax.swing.BorderFactory.createMatteBorder(2, 2, 2, 2, + defaultBorderColor), false, // Recursive + listener); + } // end constructor + + /** + * Constructor with a default border, debugging optionally turned on and the + * option to recursively set drop targets. If your component is a + * {@code java.awt.Container}, then each of its children components will + * also listen for drops, though only the parent will change borders. With + * Debugging turned on, more status messages will be displayed to + * {@code out}. A common way to use this constructor is with + * {@code System.out} or {@code System.err}. A {@code null} value for the + * parameter {@code out} will result in no debugging output. + * + * @param out + * PrintStream to record debugging info or null for no debugging. + * @param c + * Component on which files will be dropped. + * @param recursive + * Recursively set children as drop targets. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.io.PrintStream out, final java.awt.Component c, + final boolean recursive, final Listener listener) { + this(out, // Logging stream + c, // Drop target + javax.swing.BorderFactory.createMatteBorder(2, 2, 2, 2, + defaultBorderColor), // Drag border + recursive, // Recursive + listener); + } // end constructor + + /** + * Constructor with a specified border + * + * @param c + * Component on which files will be dropped. + * @param dragBorder + * Border to use on {@code JComponent} when dragging occurs. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.awt.Component c, + final javax.swing.border.Border dragBorder, final Listener listener) { + this(null, // Logging stream + c, // Drop target + dragBorder, // Drag border + false, // Recursive + listener); + } // end constructor + + /** + * Constructor with a specified border and the option to recursively set + * drop targets. If your component is a {@code java.awt.Container}, then + * each of its children components will also listen for drops, though only + * the parent will change borders. + * + * @param c + * Component on which files will be dropped. + * @param dragBorder + * Border to use on {@code JComponent} when dragging occurs. + * @param recursive + * Recursively set children as drop targets. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.awt.Component c, + final javax.swing.border.Border dragBorder, + final boolean recursive, final Listener listener) { + this(null, c, dragBorder, recursive, listener); + } // end constructor + + /** + * Constructor with a specified border and debugging optionally turned on. + * With Debugging turned on, more status messages will be displayed to + * {@code out}. A common way to use this constructor is with + * {@code System.out} or {@code System.err}. A {@code null} value for the + * parameter {@code out} will result in no debugging output. + * + * @param out + * PrintStream to record debugging info or null for no debugging. + * @param c + * Component on which files will be dropped. + * @param dragBorder + * Border to use on {@code JComponent} when dragging occurs. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.io.PrintStream out, final java.awt.Component c, + final javax.swing.border.Border dragBorder, final Listener listener) { + this(out, // Logging stream + c, // Drop target + dragBorder, // Drag border + false, // Recursive + listener); + } // end constructor + + /** + * Full constructor with a specified border and debugging optionally turned + * on. With Debugging turned on, more status messages will be displayed to + * {@code out}. A common way to use this constructor is with + * {@code System.out} or {@code System.err}. A {@code null} value for the + * parameter {@code out} will result in no debugging output. + * + * @param out + * PrintStream to record debugging info or null for no debugging. + * @param c + * Component on which files will be dropped. + * @param dragBorder + * Border to use on {@code JComponent} when dragging occurs. + * @param recursive + * Recursively set children as drop targets. + * @param listener + * Listens for {@code filesDropped}. + * @since 1.0 + */ + protected FileDrop(final java.io.PrintStream out, final java.awt.Component c, + final javax.swing.border.Border dragBorder, + final boolean recursive, final Listener listener) { + + if (supportsDnD()) { // Make a drop listener + dropListener = new java.awt.dnd.DropTargetListener() { + @Override + public void dragEnter(final java.awt.dnd.DropTargetDragEvent evt) { + log(out, "FileDrop: dragEnter event."); + + // Is this an acceptable drag event? + if (isDragOk(out, evt) && c.isEnabled()) { + // If it's a Swing component, set its border + if (c instanceof javax.swing.JComponent) { + final javax.swing.JComponent jc = (javax.swing.JComponent) c; + if (normalBorder == null) { + normalBorder = jc.getBorder(); + } // end if: border not yet saved + log(out, "FileDrop: normal border saved."); + jc.setBorder(dragBorder); + log(out, "FileDrop: drag border set."); + } // end if: JComponent + + // Acknowledge that it's okay to enter + // evt.acceptDrag( + // java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE ); + evt.acceptDrag(java.awt.dnd.DnDConstants.ACTION_COPY); + log(out, "FileDrop: event accepted."); + } // end if: drag ok + else { // Reject the drag event + evt.rejectDrag(); + log(out, "FileDrop: event rejected."); + } // end else: drag not ok + } // end dragEnter + + @Override + public void dragOver(final java.awt.dnd.DropTargetDragEvent evt) { // This + // is + // called + // continually + // as + // long + // as + // the + // mouse + // is + // over + // the + // drag + // target. + } // end dragOver + + @Override + public void drop(final java.awt.dnd.DropTargetDropEvent evt) { + log(out, "FileDrop: drop event."); + try { // Get whatever was dropped + final java.awt.datatransfer.Transferable tr = evt + .getTransferable(); + + // Is it a file list? + if (tr.isDataFlavorSupported(java.awt.datatransfer.DataFlavor.javaFileListFlavor)) { + // Say we'll take it. + // evt.acceptDrop ( + // java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE ); + evt.acceptDrop(java.awt.dnd.DnDConstants.ACTION_COPY); + log(out, "FileDrop: file list accepted."); + + // Get a useful list + final java.util.List fileList = (java.util.List) tr + .getTransferData(java.awt.datatransfer.DataFlavor.javaFileListFlavor); + //final java.util.Iterator iterator = fileList.iterator(); + + // Convert list to array + final java.io.File[] filesTemp = new java.io.File[fileList + .size()]; + fileList.toArray(filesTemp); + final java.io.File[] files = filesTemp; + + // Alert listener to drop. + if (listener != null) + listener.filesDropped(files); + + // Mark that drop is completed. + evt.getDropTargetContext().dropComplete(true); + log(out, "FileDrop: drop complete."); + } // end if: file list + else // this section will check for a reader flavor. + { + // Thanks, Nathan! + // BEGIN 2007-09-12 Nathan Blomquist -- Linux + // (KDE/Gnome) support added. + final DataFlavor[] flavors = tr.getTransferDataFlavors(); + boolean handled = false; + for (int zz = 0; zz < flavors.length; zz++) { + if (flavors[zz].isRepresentationClassReader()) { + // Say we'll take it. + // evt.acceptDrop ( + // java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE + // ); + evt.acceptDrop(java.awt.dnd.DnDConstants.ACTION_COPY); + log(out, "FileDrop: reader accepted."); + + final Reader reader = flavors[zz] + .getReaderForText(tr); + + final BufferedReader br = new BufferedReader( + reader); + + if (listener != null) + listener.filesDropped(createFileArray( + br, out)); + + // Mark that drop is completed. + evt.getDropTargetContext().dropComplete( + true); + log(out, "FileDrop: drop complete."); + handled = true; + break; + } + } + if (!handled) { + log(out, + "FileDrop: not a file list or reader - abort."); + evt.rejectDrop(); + } + // END 2007-09-12 Nathan Blomquist -- Linux + // (KDE/Gnome) support added. + } // end else: not a file list + } // end try + catch (final java.io.IOException io) { + log(out, "FileDrop: IOException - abort:"); + io.printStackTrace(out); + evt.rejectDrop(); + } // end catch IOException + catch (final java.awt.datatransfer.UnsupportedFlavorException ufe) { + log(out, + "FileDrop: UnsupportedFlavorException - abort:"); + ufe.printStackTrace(out); + evt.rejectDrop(); + } // end catch: UnsupportedFlavorException + finally { + // If it's a Swing component, reset its border + if (c instanceof javax.swing.JComponent) { + final javax.swing.JComponent jc = (javax.swing.JComponent) c; + jc.setBorder(normalBorder); + log(out, "FileDrop: normal border restored."); + } // end if: JComponent + } // end finally + } // end drop + + @Override + public void dragExit(final java.awt.dnd.DropTargetEvent evt) { + log(out, "FileDrop: dragExit event."); + // If it's a Swing component, reset its border + if (c instanceof javax.swing.JComponent) { + final javax.swing.JComponent jc = (javax.swing.JComponent) c; + jc.setBorder(normalBorder); + log(out, "FileDrop: normal border restored."); + } // end if: JComponent + } // end dragExit + + @Override + public void dropActionChanged( + final java.awt.dnd.DropTargetDragEvent evt) { + log(out, "FileDrop: dropActionChanged event."); + // Is this an acceptable drag event? + if (isDragOk(out, evt)) { // evt.acceptDrag( + // java.awt.dnd.DnDConstants.ACTION_COPY_OR_MOVE + // ); + evt.acceptDrag(java.awt.dnd.DnDConstants.ACTION_COPY); + log(out, "FileDrop: event accepted."); + } // end if: drag ok + else { + evt.rejectDrag(); + log(out, "FileDrop: event rejected."); + } // end else: drag not ok + } // end dropActionChanged + }; // end DropTargetListener + + // Make the component (and possibly children) drop targets + makeDropTarget(out, c, recursive); + } // end if: supports dnd + else { + log(out, "FileDrop: Drag and drop is not supported with this JVM"); + } // end else: does not support DnD + } // end constructor + + private static boolean supportsDnD() { // Static Boolean + if (supportsDnD == null) { + boolean support = false; + try { + Class.forName("java.awt.dnd.DnDConstants"); + support = true; + } // end try + catch (final Exception e) { + support = false; + } // end catch + supportsDnD = Boolean.valueOf(support); + } // end if: first time through + return supportsDnD.booleanValue(); + } // end supportsDnD + + // BEGIN 2007-09-12 Nathan Blomquist -- Linux (KDE/Gnome) support added. + private static String ZERO_CHAR_STRING = "" + (char) 0; + + private static File[] createFileArray(final BufferedReader bReader, + final PrintStream out) { + try { + final java.util.List list = new java.util.ArrayList(); + java.lang.String line = null; + while ((line = bReader.readLine()) != null) { + try { + // kde seems to append a 0 char to the end of the reader + if (ZERO_CHAR_STRING.equals(line)) + continue; + + final java.io.File file = new java.io.File(new java.net.URI(line)); + list.add(file); + } catch (final Exception ex) { + log(out, "Error with " + line + ": " + ex.getMessage()); + } + } + + return list.toArray(new File[list.size()]); + } catch (final IOException ex) { + log(out, "FileDrop: IOException"); + } + return new File[0]; + } + + // END 2007-09-12 Nathan Blomquist -- Linux (KDE/Gnome) support added. + + private void makeDropTarget(final java.io.PrintStream out, + final java.awt.Component c, final boolean recursive) { + // Make drop target + final java.awt.dnd.DropTarget dt = new java.awt.dnd.DropTarget(); + try { + dt.addDropTargetListener(dropListener); + } // end try + catch (final java.util.TooManyListenersException e) { + e.printStackTrace(); + log(out, + "FileDrop: Drop will not work due to previous error. Do you have another listener attached?"); + } // end catch + + // Listen for hierarchy changes and remove the drop target when the + // parent gets cleared out. + c.addHierarchyListener(new java.awt.event.HierarchyListener() { + @Override + public void hierarchyChanged(final java.awt.event.HierarchyEvent evt) { + log(out, "FileDrop: Hierarchy changed."); + final java.awt.Component parent = c.getParent(); + if (parent == null) { + c.setDropTarget(null); + log(out, "FileDrop: Drop target cleared from component."); + } // end if: null parent + else { + new java.awt.dnd.DropTarget(c, dropListener); + log(out, "FileDrop: Drop target added to component."); + } // end else: parent not null + } // end hierarchyChanged + }); // end hierarchy listener + if (c.getParent() != null) + new java.awt.dnd.DropTarget(c, dropListener); + + if (recursive && (c instanceof java.awt.Container)) { + // Get the container + final java.awt.Container cont = (java.awt.Container) c; + + // Get it's components + final java.awt.Component[] comps = cont.getComponents(); + + // Set it's components as listeners also + for (int i = 0; i < comps.length; i++) + makeDropTarget(out, comps[i], recursive); + } // end if: recursively set components as listener + } // end dropListener + + /** Determine if the dragged data is a file list. */ + private boolean isDragOk(final java.io.PrintStream out, + final java.awt.dnd.DropTargetDragEvent evt) { + boolean ok = false; + + // Get data flavors being dragged + final java.awt.datatransfer.DataFlavor[] flavors = evt + .getCurrentDataFlavors(); + + // See if any of the flavors are a file list + int i = 0; + while (!ok && i < flavors.length) { + // BEGIN 2007-09-12 Nathan Blomquist -- Linux (KDE/Gnome) support + // added. + // Is the flavor a file list? + final DataFlavor curFlavor = flavors[i]; + if (curFlavor + .equals(java.awt.datatransfer.DataFlavor.javaFileListFlavor) + || curFlavor.isRepresentationClassReader()) { + ok = true; + } + // END 2007-09-12 Nathan Blomquist -- Linux (KDE/Gnome) support + // added. + i++; + } // end while: through flavors + + // If logging is enabled, show data flavors + if (out != null) { + if (flavors.length == 0) + log(out, "FileDrop: no data flavors."); + for (i = 0; i < flavors.length; i++) + log(out, flavors[i].toString()); + } // end if: logging enabled + + return ok; + } // end isDragOk + + /** Outputs {@code message} to {@code out} if it's not null. */ + private static void log(final java.io.PrintStream out, final String message) { // Log + // message + // if + // requested + if (out != null) + out.println(message); + } // end log + + /** + * Removes the drag-and-drop hooks from the component and optionally from + * the all children. You should call this if you add and remove components + * after you've set up the drag-and-drop. This will recursively unregister + * all components contained within c if c is a + * {@link java.awt.Container}. + * + * @param c The component to unregister as a drop target + * @return true, if successful + * @since 1.0 + */ + public static boolean remove(final java.awt.Component c) { + return remove(null, c, true); + } // end remove + + /** + * Removes the drag-and-drop hooks from the component and optionally from + * the all children. You should call this if you add and remove components + * after you've set up the drag-and-drop. + * + * @param out Optional {@link java.io.PrintStream} for logging drag and drop + * messages + * @param c The component to unregister + * @param recursive Recursively unregister components within a container + * @return true, if successful + * @since 1.0 + */ + protected static boolean remove(final java.io.PrintStream out, final java.awt.Component c, + final boolean recursive) { // Make sure we support dnd. + if (supportsDnD()) { + log(out, "FileDrop: Removing drag-and-drop hooks."); + c.setDropTarget(null); + if (recursive && (c instanceof java.awt.Container)) { + final java.awt.Component[] comps = ((java.awt.Container) c) + .getComponents(); + for (int i = 0; i < comps.length; i++) + remove(out, comps[i], recursive); + return true; + } // end if: recursive + else + return false; + } // end if: supports DnD + else + return false; + } // end remove + + /* ******** I N N E R I N T E R F A C E L I S T E N E R ******** */ + + /** + * Implement this inner interface to listen for when files are dropped. For + * example your class declaration may begin like this: + *
+	 *      public class MyClass implements FileDrop.Listener
+	 *      ...
+	 *      public void filesDropped( java.io.File[] files )
+	 *      {
+	 *          ...
+	 *      }   // end filesDropped
+	 *      ...
+	 * 
+ * + * @since 1.1 + */ + public static interface Listener { + + /** + * This method is called when files have been successfully dropped. + * + * @param files + * An array of {@code File}s that were dropped. + * @since 1.0 + */ + public abstract void filesDropped(java.io.File[] files); + + } // end inner-interface Listener + + /* ******** I N N E R C L A S S ******** */ + + /** + * This is the event that is passed to the + * {@link FileDrop.Listener#filesDropped filesDropped(...)} method in your + * {@link FileDrop.Listener} when files are dropped onto a registered drop + * target. + * + *

+ * I'm releasing this code into the Public Domain. Enjoy. + *

+ * + * @author Robert Harder + * @author rob@iharder.net + * @version 1.2 + */ + @SuppressWarnings("serial") + static class Event extends java.util.EventObject { + + private final java.io.File[] files; + + /** + * Constructs an {@link Event} with the array of files that were dropped + * and the {@link FileDrop} that initiated the event. + * + * @param files + * The array of files that were dropped + * @param source + * The event source + * @since 1.1 + */ + public Event(final java.io.File[] files, final Object source) { + super(source); + this.files = files; + } // end constructor + + /** + * Returns an array of files that were dropped on a registered drop + * target. + * + * @return array of files that were dropped + * @since 1.1 + */ + public java.io.File[] getFiles() { + return files; + } // end getFiles + + } // end inner class Event + + /* ******** I N N E R C L A S S ******** */ + + /** + * At last an easy way to encapsulate your custom objects for dragging and + * dropping in your Java programs! When you need to create a + * {@link java.awt.datatransfer.Transferable} object, use this class to wrap + * your object. For example: + * + *
+	 *      ...
+	 *      MyCoolClass myObj = new MyCoolClass();
+	 *      Transferable xfer = new TransferableObject( myObj );
+	 *      ...
+	 * 
+ * + * Or if you need to know when the data was actually dropped, like when + * you're moving data out of a list, say, you can use the + * {@link TransferableObject.Fetcher} inner class to return your object Just + * in Time. For example: + * + *
+	 *      ...
+	 *      final MyCoolClass myObj = new MyCoolClass();
+	 * 
+	 *      TransferableObject.Fetcher fetcher = new TransferableObject.Fetcher()
+	 *      {   public Object getObject(){ return myObj; }
+	 *      }; // end fetcher
+	 * 
+	 *      Transferable xfer = new TransferableObject( fetcher );
+	 *      ...
+	 * 
+ * + * The {@link java.awt.datatransfer.DataFlavor} associated with + * {@link TransferableObject} has the representation class + * {@code net.iharder.dnd.TransferableObject.class} and MIME type + * {@code application/x-net.iharder.dnd.TransferableObject}. This data + * flavor is accessible via the static {@link #DATA_FLAVOR} property. + * + * + *

+ * I'm releasing this code into the Public Domain. Enjoy. + *

+ * + * @author Robert Harder + * @author rob@iharder.net + * @version 1.2 + */ + static class TransferableObject implements + java.awt.datatransfer.Transferable { + /** + * The MIME type for {@link #DATA_FLAVOR} is + * {@code application/x-net.iharder.dnd.TransferableObject}. + * + * @since 1.1 + */ + public final static String MIME_TYPE = "application/x-net.iharder.dnd.TransferableObject"; + + /** + * The default {@link java.awt.datatransfer.DataFlavor} for + * {@link TransferableObject} has the representation class + * {@code net.iharder.dnd.TransferableObject.class} and the MIME type + * {@code application/x-net.iharder.dnd.TransferableObject}. + * + * @since 1.1 + */ + public final static java.awt.datatransfer.DataFlavor DATA_FLAVOR = new java.awt.datatransfer.DataFlavor( + FileDrop.TransferableObject.class, MIME_TYPE); + + private Fetcher fetcher; + private Object data; + + private java.awt.datatransfer.DataFlavor customFlavor; + + /** + * Creates a new {@link TransferableObject} that wraps data. + * Along with the {@link #DATA_FLAVOR} associated with this class, this + * creates a custom data flavor with a representation class determined + * from data.getClass() and the MIME type + * {@code application/x-net.iharder.dnd.TransferableObject}. + * + * @param data + * The data to transfer + * @since 1.1 + */ + public TransferableObject(final Object data) { + this.data = data; + this.customFlavor = new java.awt.datatransfer.DataFlavor( + data.getClass(), MIME_TYPE); + } // end constructor + + /** + * Creates a new {@link TransferableObject} that will return the object + * that is returned by fetcher. No custom data flavor is set + * other than the default {@link #DATA_FLAVOR}. + * + * @see Fetcher + * @param fetcher + * The {@link Fetcher} that will return the data object + * @since 1.1 + */ + public TransferableObject(final Fetcher fetcher) { + this.fetcher = fetcher; + } // end constructor + + /** + * Creates a new {@link TransferableObject} that will return the object + * that is returned by fetcher. Along with the + * {@link #DATA_FLAVOR} associated with this class, this creates a + * custom data flavor with a representation class dataClass + * and the MIME type + * {@code application/x-net.iharder.dnd.TransferableObject}. + * + * @see Fetcher + * @param dataClass + * The {@link java.lang.Class} to use in the custom data + * flavor + * @param fetcher + * The {@link Fetcher} that will return the data object + * @since 1.1 + */ + public TransferableObject(final Class dataClass, final Fetcher fetcher) { + this.fetcher = fetcher; + this.customFlavor = new java.awt.datatransfer.DataFlavor(dataClass, + MIME_TYPE); + } // end constructor + + /** + * Returns the custom {@link java.awt.datatransfer.DataFlavor} + * associated with the encapsulated object or {@code null} if the + * {@link Fetcher} constructor was used without passing a + * {@link java.lang.Class}. + * + * @return The custom data flavor for the encapsulated object + * @since 1.1 + */ + public java.awt.datatransfer.DataFlavor getCustomDataFlavor() { + return customFlavor; + } // end getCustomDataFlavor + + /* ******** T R A N S F E R A B L E M E T H O D S ******** */ + + /** + * Returns a two- or three-element array containing first the custom + * data flavor, if one was created in the constructors, second the + * default {@link #DATA_FLAVOR} associated with + * {@link TransferableObject}, and third the + * {@link java.awt.datatransfer.DataFlavor#stringFlavor}. + * + * @return An array of supported data flavors + * @since 1.1 + */ + @Override + public java.awt.datatransfer.DataFlavor[] getTransferDataFlavors() { + if (customFlavor != null) + return new java.awt.datatransfer.DataFlavor[] { customFlavor, + DATA_FLAVOR, + java.awt.datatransfer.DataFlavor.stringFlavor }; // end + // flavors + // array + else + return new java.awt.datatransfer.DataFlavor[] { DATA_FLAVOR, + java.awt.datatransfer.DataFlavor.stringFlavor }; // end + // flavors + // array + } // end getTransferDataFlavors + + /** + * Returns the data encapsulated in this {@link TransferableObject}. If + * the {@link Fetcher} constructor was used, then this is when the + * {@link Fetcher#getObject getObject()} method will be called. If the + * requested data flavor is not supported, then the + * {@link Fetcher#getObject getObject()} method will not be called. + * + * @param flavor + * The data flavor for the data to return + * @return The dropped data + * @since 1.1 + */ + @Override + public Object getTransferData(final java.awt.datatransfer.DataFlavor flavor) + throws java.awt.datatransfer.UnsupportedFlavorException, + java.io.IOException { + // Native object + if (flavor.equals(DATA_FLAVOR)) + return fetcher == null ? data : fetcher.getObject(); + + // String + if (flavor.equals(java.awt.datatransfer.DataFlavor.stringFlavor)) + return fetcher == null ? data.toString() : fetcher.getObject() + .toString(); + + // We can't do anything else + throw new java.awt.datatransfer.UnsupportedFlavorException(flavor); + } // end getTransferData + + /** + * Returns {@code true} if flavor is one of the supported + * flavors. Flavors are supported using the equals(...) + * method. + * + * @param flavor + * The data flavor to check + * @return Whether or not the flavor is supported + * @since 1.1 + */ + @Override + public boolean isDataFlavorSupported( + final java.awt.datatransfer.DataFlavor flavor) { + // Native object + if (flavor.equals(DATA_FLAVOR)) + return true; + + // String + if (flavor.equals(java.awt.datatransfer.DataFlavor.stringFlavor)) + return true; + + // We can't do anything else + return false; + } // end isDataFlavorSupported + + /* ******** I N N E R I N T E R F A C E F E T C H E R ******** */ + + /** + * Instead of passing your data directly to the + * {@link TransferableObject} constructor, you may want to know exactly + * when your data was received in case you need to remove it from its + * source (or do anyting else to it). When the {@link #getTransferData + * getTransferData(...)} method is called on the + * {@link TransferableObject}, the {@link Fetcher}'s {@link #getObject + * getObject()} method will be called. + * + * @author Robert Harder + * @version 1.1 + * @since 1.1 + */ + public static interface Fetcher { + /** + * Return the object being encapsulated in the + * {@link TransferableObject}. + * + * @return The dropped object + * @since 1.1 + */ + public abstract Object getObject(); + } // end inner interface Fetcher + + } // end class TransferableObject + +} // end class FileDrop diff --git a/src/main/java/org/scijava/ui/swing/script/FileFunctions.java b/src/main/java/org/scijava/ui/swing/script/FileFunctions.java index cd6c17b9..cf1ac831 100644 --- a/src/main/java/org/scijava/ui/swing/script/FileFunctions.java +++ b/src/main/java/org/scijava/ui/swing/script/FileFunctions.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -93,13 +93,14 @@ public List extractSourceJar(final String path, final File workspace) final File baseDirectory = new File(workspace, baseName); final List result = new ArrayList<>(); - final JarFile jar = new JarFile(path); - for (final JarEntry entry : Collections.list(jar.entries())) { - final String name = entry.getName(); - if (name.endsWith(".class") || name.endsWith("/")) continue; - final String destination = baseDirectory + name; - copyTo(jar.getInputStream(entry), destination); - result.add(destination); + try (JarFile jar = new JarFile(path)) { + for (final JarEntry entry : Collections.list(jar.entries())) { + final String name = entry.getName(); + if (name.endsWith(".class") || name.endsWith("/")) continue; + final String destination = baseDirectory + name; + copyTo(jar.getInputStream(entry), destination); + result.add(destination); + } } return result; } @@ -156,8 +157,12 @@ public boolean isBinaryFile(final String path) { } /** - * @deprecated Use {@link #getSourceURL(String)} instead. + * Gets the source path. + * + * @param className the class name + * @return the source path * @throws ClassNotFoundException + * @deprecated Use {@link #getSourceURL(String)} instead. */ @Deprecated public String getSourcePath( @@ -219,7 +224,7 @@ public String findSourcePath(final String className, final File workspace) { } if (paths.size() == 1) return new File(workspace, paths.get(0)) .getAbsolutePath(); - final String[] names = paths.toArray(new String[paths.size()]); + //final String[] names = paths.toArray(new String[paths.size()]); final JFileChooser chooser = new JFileChooser(workspace); chooser.setDialogTitle("Choose path"); if (chooser.showOpenDialog(parent) != JFileChooser.APPROVE_OPTION) return null; @@ -266,7 +271,11 @@ protected String readStream(final InputStream in) throws IOException { } /** - * Get a list of files from a directory (recursively) + * Get a list of files from a directory (recursively). + * + * @param directory the directory to be parsed + * @param prefix a prefix to prepend to filenames + * @param result List holding filenames */ public void listFilesRecursively(final File directory, final String prefix, final List result) @@ -279,9 +288,12 @@ public void listFilesRecursively(final File directory, final String prefix, } /** - * Get a list of files from a directory or within a .jar file The returned - * items will only have the base path, to get at the full URL you have to - * prefix the url passed to the function. + * Get a list of files from a directory or within a .jar file The returned items + * will only have the base path, to get at the full URL you have to prefix the + * url passed to the function. + * + * @param url the string specifying the resource + * @return the list of files */ public List getResourceList(String url) { final List result = new ArrayList<>(); @@ -293,8 +305,7 @@ public List getResourceList(String url) { final String prefix = url.substring(bang + 2); final int prefixLength = prefix.length(); - try { - final JarFile jar = new JarFile(jarURL); + try (JarFile jar = new JarFile(jarURL)) { final Enumeration e = jar.entries(); while (e.hasMoreElements()) { final JarEntry entry = e.nextElement(); @@ -493,11 +504,11 @@ public void openInGitweb(final File file, final File gitDirectory, final int line) { if (file == null || gitDirectory == null) { - error("No file or git directory"); + parent.error("No file or git directory."); return; } final String url = getGitwebURL(file, gitDirectory, line); - if (url == null) error("Could not get gitweb URL for " + file); + if (url == null) parent.error("Could not get gitweb URL for " + file + "."); else try { parent.getPlatformService().open(new URL(url)); } @@ -588,11 +599,6 @@ protected String stripSuffix(final String string, final String suffix) { return string; } - protected boolean error(final String message) { - JOptionPane.showMessageDialog(parent, message); - return false; - } - /** * @deprecated Use {@link FileUtils#findResources(String, String, File)} * instead. diff --git a/src/main/java/org/scijava/ui/swing/script/FileSystemTree.java b/src/main/java/org/scijava/ui/swing/script/FileSystemTree.java index f64b8cb3..75cfdd1f 100644 --- a/src/main/java/org/scijava/ui/swing/script/FileSystemTree.java +++ b/src/main/java/org/scijava/ui/swing/script/FileSystemTree.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -132,10 +132,9 @@ public boolean isDirectory() { } /** - * - * @param sort + * @param sort whether result should be sorted * @param file_filter Applies to leafs, not to directories. - * @return + * @return the array denoting children files */ public File[] updatedChildrenFiles(final boolean sort, final FileFilter file_filter) { final File file = new File(this.path); @@ -156,6 +155,9 @@ public boolean accept(final File f) { /** * If it's a directory, add a Node for each of its visible files. + * + * @param model the tree model + * @param file_filter Applies to leafs, not to directories. */ public synchronized void populateChildren(final DefaultTreeModel model, final FileFilter file_filter) { try { @@ -252,7 +254,7 @@ public interface LeafListener { public void leafDoubleClicked(final File file); } - private final Logger log; + final Logger log; private ArrayList leaf_listeners = new ArrayList<>(); @@ -270,6 +272,7 @@ public FileSystemTree(final Logger log) getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); setAutoscrolls(true); setScrollsOnExpand(true); + setExpandsSelectedPaths(true); addTreeWillExpandListener(new TreeWillExpandListener() { @Override public void treeWillExpand(TreeExpansionEvent event) throws ExpandVetoException { @@ -439,9 +442,10 @@ public void addRootDirectory(final String dir, final boolean checkIfChild) { final TreePath[] p = new TreePath[1]; node.expandTo(dirPath, p); if (null != p[0]) { - getModel().reload(); + //getModel().reload(); // this will collapse all nodes expandPath(p[0]); - scrollPathToVisible(p[0]); + setSelectionPath(p[0]); + scrollPathToVisible(p[0]); //spurious!? return; } } @@ -449,7 +453,8 @@ public void addRootDirectory(final String dir, final boolean checkIfChild) { } // Else, append it as a new root getModel().insertNodeInto(new Node(dirPath), root, root.getChildCount()); - getModel().reload(); + //getModel().reload(); // this will collapse all nodes + getModel().nodesWereInserted(root, new int[] { root.getChildCount() - 1 }); } @Override @@ -491,6 +496,7 @@ public void addTopLevelFoldersFrom(final String folders) { public void destroy() { dir_watcher.interrupt(); + FileDrop.remove(this); } private class DirectoryWatcher extends Thread { @@ -500,6 +506,7 @@ private class DirectoryWatcher extends Thread { private final HashMap map = new HashMap<>(); DirectoryWatcher() { + setDaemon(true); try { this.watcher = FileSystems.getDefault().newWatchService(); this.start(); diff --git a/src/main/java/org/scijava/ui/swing/script/FileSystemTreePanel.java b/src/main/java/org/scijava/ui/swing/script/FileSystemTreePanel.java new file mode 100644 index 00000000..490f4597 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/FileSystemTreePanel.java @@ -0,0 +1,459 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script; + +import java.awt.Color; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.font.FontRenderContext; +import java.awt.geom.Rectangle2D; +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JFileChooser; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.UIManager; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreePath; + +import org.scijava.Context; +import org.scijava.app.AppService; +import org.scijava.plugin.Parameter; + +/** + * Convenience class for displaying a {@link FileSystemTree} with some bells and + * whistles, including a filter toolbar. + * + * @author Albert Cardona + * @author Tiago Ferreira + */ +class FileSystemTreePanel extends JPanel { + + private static final long serialVersionUID = -710040159139542578L; + private final FileSystemTree tree; + private final SearchField searchField; + private boolean regex; + private boolean caseSensitive; + + @Parameter + private AppService appService; + + FileSystemTreePanel(final FileSystemTree tree, final Context context) { + this.tree = tree; + context.inject(this); + searchField = initializedField(); + setLayout(new GridBagLayout()); + final GridBagConstraints bc = new GridBagConstraints(); + bc.gridx = 0; + bc.gridy = 0; + bc.weightx = 0; + bc.weighty = 0; + bc.anchor = GridBagConstraints.CENTER; + bc.fill = GridBagConstraints.HORIZONTAL; + add(addDirectoryButton(), bc); + bc.gridx = 1; + add(removeDirectoryButton(), bc); + bc.gridx = 2; + bc.fill = GridBagConstraints.BOTH; + bc.weightx = 1; + add(searchField, bc); + bc.fill = GridBagConstraints.NONE; + bc.weightx = 0; + bc.gridx = 3; + add(searchOptionsButton(), bc); + bc.gridx = 0; + bc.gridwidth = 4; + bc.gridy = 1; + bc.weightx = 1.0; + bc.weighty = 1.0; + bc.fill = GridBagConstraints.BOTH; + final JScrollPane treePane = new JScrollPane(tree); + add(treePane, bc); + new FileDrop(treePane, files -> { + final List dirs = Arrays.asList(files).stream().filter(f -> f.isDirectory()) + .collect(Collectors.toList()); + if (dirs.isEmpty()) { + TextEditor.GuiUtils.warn(this, "Only folders can be dropped into the file tree."); + return; + } + if (TextEditor.GuiUtils.confirm(this, "Confirm loading of " + dirs.size() + " folders?", "Confirm?", + "Confirm")) { + dirs.forEach(dir -> tree.addRootDirectory(dir.getAbsolutePath(), true)); + } + }); + addContextualMenuToTree(); + } + + private SearchField initializedField() { + final SearchField field = new SearchField(); + field.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(final FocusEvent e) { + if (0 == field.getText().length()) { + tree.setFileFilter(((f) -> true)); // any // no need to press enter + } + } + }); + field.addKeyListener(new KeyAdapter() { + Pattern pattern = null; + + @Override + public void keyPressed(final KeyEvent ke) { + if (ke.getKeyCode() == KeyEvent.VK_ENTER) { + final String text = field.getText(); + if (0 == text.length()) { + tree.setFileFilter(((f) -> true)); // any + return; + } + + if (isRegexEnabled()) { // if ('/' == text.charAt(0)) { + // Interpret as a regular expression + // Attempt to compile the pattern + try { + String regex = text; // text.substring(1); + if ('^' != regex.charAt(1)) + regex = "^.*" + regex; + if ('$' != regex.charAt(regex.length() - 1)) + regex += ".*$"; + pattern = Pattern.compile(regex); + field.setForeground(tree.getForeground()); + } catch (final PatternSyntaxException | StringIndexOutOfBoundsException pse) { + // regex is too short to be parseable or is invalid + tree.log.warn(pse.getLocalizedMessage()); + field.setForeground(Color.RED); + pattern = null; + return; + } + if (null != pattern) { + tree.setFileFilter((f) -> pattern.matcher(f.getName()).matches()); + } + } else { + // Interpret as a literal match + if (isCaseSensitive()) + tree.setFileFilter((f) -> -1 != f.getName().indexOf(text)); + else + tree.setFileFilter((f) -> -1 != f.getName().toLowerCase().indexOf(text.toLowerCase())); + } + } else { + // Upon re-typing something + if (field.getForeground() == Color.RED) { + field.setForeground(tree.getForeground()); + } + } + } + }); + return field; + } + + private JButton thinButton(final String label, final float factor) { + final JButton b = new JButton(label); + try { + if ("com.apple.laf.AquaLookAndFeel".equals(UIManager.getLookAndFeel().getClass().getName())) { + b.setOpaque(true); + b.setBackground(new JPanel().getBackground()); + b.setBorderPainted(false); + b.setBorder(BorderFactory.createEmptyBorder()); + b.setMargin(new Insets(0, 2, 0, 2)); + } else { + final Insets insets = b.getMargin(); + b.setMargin(new Insets((int) (insets.top * factor), (int) (insets.left * factor), + (int) (insets.bottom * factor), (int) (insets.right * factor))); + } + } catch (final Exception ignored) { + // do nothing + } + // b.setBorder(null); + // set height to that of searchField. Do not allow vertical resizing + b.setPreferredSize(new Dimension(b.getPreferredSize().width, (int) searchField.getPreferredSize().getHeight())); + b.setMaximumSize(new Dimension(b.getMaximumSize().width, (int) searchField.getPreferredSize().getHeight())); + return b; + } + + private JButton addDirectoryButton() { + final JButton add_directory = thinButton("+", .25f); + add_directory.setToolTipText("Add a directory"); + add_directory.addActionListener(e -> { + final String folders = tree.getTopLevelFoldersString(); + final String lastFolder = folders.substring(folders.lastIndexOf(":") + 1); + final JFileChooser c = new JFileChooser(); + c.setDialogTitle("Choose Top-Level Folder"); + c.setCurrentDirectory(new File(lastFolder)); + c.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + c.setFileHidingEnabled(true); // hide hidden files + c.setAcceptAllFileFilterUsed(false); // disable "All files" as it has no meaning here + c.setApproveButtonText("Choose Folder"); + c.setMultiSelectionEnabled(false); + c.setDragEnabled(true); + new FileDrop(c, files -> { + if (files.length == 0) + return; + final File firstFile = files[0]; + c.setCurrentDirectory((firstFile.isDirectory()) ? firstFile : firstFile.getParentFile()); + c.rescanCurrentDirectory(); + }); + if (JFileChooser.APPROVE_OPTION == c.showOpenDialog(this)) { + final File f = c.getSelectedFile(); + if (f.isDirectory()) + tree.addRootDirectory(f.getAbsolutePath(), false); + } + FileDrop.remove(c); + }); + return add_directory; + } + + private JButton removeDirectoryButton() { + final JButton remove_directory = thinButton("−", .25f); + remove_directory.setToolTipText("Remove a top-level directory"); + remove_directory.addActionListener(e -> { + final TreePath p = tree.getSelectionPath(); + if (null == p) { + TextEditor.GuiUtils.error(this, "Select a top-level folder first."); + return; + } + if (2 == p.getPathCount()) { + // Is a child of the root, so it's a top-level folder + tree.getModel().removeNodeFromParent(// + (FileSystemTree.Node) p.getLastPathComponent()); + } else { + TextEditor.GuiUtils.error(this, "Can only remove top-level folders."); + } + }); + return remove_directory; + } + + private JButton searchOptionsButton() { + final JButton options = thinButton("⋮", .05f); + options.setToolTipText("Filtering options"); + final JPopupMenu popup = new JPopupMenu(); + final JCheckBoxMenuItem jcbmi1 = new JCheckBoxMenuItem("Case Sensitive", isCaseSensitive()); + jcbmi1.addItemListener(e -> { + setCaseSensitive(jcbmi1.isSelected()); + }); + popup.add(jcbmi1); + final JCheckBoxMenuItem jcbmi2 = new JCheckBoxMenuItem("Enable Regex", isCaseSensitive()); + jcbmi2.addItemListener(e -> { + setRegexEnabled(jcbmi2.isSelected()); + }); + popup.add(jcbmi2); + popup.addSeparator(); + JMenuItem jmi = new JMenuItem("Reset Filter"); + jmi.addActionListener(e -> { + jcbmi1.setSelected(false); + setCaseSensitive(false); + jcbmi2.setSelected(false); + setRegexEnabled(false); + searchField.setText(""); + tree.setFileFilter(((f) -> true)); + }); + popup.add(jmi); + popup.addSeparator(); + jmi = new JMenuItem("About File Explorer..."); + jmi.addActionListener(e -> showHelpMsg()); + popup.add(jmi); + options.addActionListener(e -> popup.show(options, options.getWidth() / 2, options.getHeight() / 2)); + return options; + } + + @SuppressWarnings("unused") + private boolean allTreeNodesCollapsed() { + for (int i = 0; i < tree.getRowCount(); i++) + if (!tree.isCollapsed(i)) + return false; + return true; + } + + private void addContextualMenuToTree() { + final JPopupMenu popup = new JPopupMenu(); + JMenuItem jmi = new JMenuItem("Collapse All"); + jmi.addActionListener(e -> TextEditor.GuiUtils.collapseAllTreeNodes(tree)); + popup.add(jmi); + jmi = new JMenuItem("Expand Folders"); + jmi.addActionListener(e -> expandImmediateNodes()); + popup.add(jmi); + popup.addSeparator(); + jmi = new JMenuItem("Open in System Explorer"); + jmi.addActionListener(e -> { + final TreePath path = tree.getSelectionPath(); + if (path == null) { + TextEditor.GuiUtils.info(this, "No items are currently selected.", "Invalid Selection"); + return; + } + try { + final String filepath = (String) ((FileSystemTree.Node) path.getLastPathComponent()).getUserObject(); + final File f = new File(filepath); + Desktop.getDesktop().open((f.isDirectory()) ? f : f.getParentFile()); + } catch (final Exception | Error ignored) { + TextEditor.GuiUtils.error(this, "Folder of selected item does not seem to be accessible."); + } + }); + popup.add(jmi); + jmi = new JMenuItem("Open in Terminal"); + jmi.addActionListener(e -> { + final TreePath path = tree.getSelectionPath(); + if (path == null) { + TextEditor.GuiUtils.info(this, "No items are currently selected.", "Invalid Selection"); + return; + } + try { + final String filepath = (String) ((FileSystemTree.Node) path.getLastPathComponent()).getUserObject(); + TextEditor.GuiUtils.openTerminal(new File(filepath)); + } catch (final Exception ignored) { + TextEditor.GuiUtils.error(this, "Could not open path in Terminal."); + } + }); + popup.add(jmi); + + popup.addSeparator(); + jmi = new JMenuItem("Reset to Home Folder"); + jmi.addActionListener(e -> changeRootPath(System.getProperty("user.home"))); + popup.add(jmi); + jmi = new JMenuItem("Reset to Fiji.app/"); + jmi.addActionListener(e -> changeRootPath(appService.getApp().getBaseDirectory().getAbsolutePath())); + popup.add(jmi); + tree.setComponentPopupMenu(popup); + } + + void changeRootPath(final String path) { + ((DefaultMutableTreeNode) tree.getModel().getRoot()).removeAllChildren(); + tree.addTopLevelFoldersFrom(path); + tree.getModel().reload(); // this will collapse all nodes + expandImmediateNodes(); + } + + private void expandImmediateNodes() { + for (int i = tree.getRowCount() - 1; i >= 0; i--) + tree.expandRow(i); + } + + private void showHelpMsg() { + final String msg = "" // + + "

Overview

" // + + "

The File Explorer pane provides a direct view of selected folders. Changes in " // + + "the native file system are synchronized in real time.

" // + + "

Add/Remove Folders

" // + + "

To add a folder, use the [+] button, or drag & drop folders from the native " // + + "System Explorer. To remove a folder: select it, then use the [-] button. To reset " + + "or reveal items: use the commands in the contextual popup menu.

" // + + "

Accessing Files & Paths

" // + + "

Double-click on a file to open it. Drag & drop items into the editor pane " + + "to paste their paths into the active script.

" // + + "

Filtering Files

" // + + "

Filters affect filenames (not folders) and are applied by typing a filtering "// + + "string + [Enter]. Filters act only on files being listed in expanded directories, " // + + "and ignore the content of collapsed folders. Examples of regex usage:

" // + + "
" // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + " " // + + "
PatternResult
py$Display filenames ending with py
^DemoDisplay filenames starting with Demo
"; + TextEditor.GuiUtils.showHTMLDialog(this.getRootPane(), "File Explorer Pane", msg); + } + + private boolean isCaseSensitive() { + return caseSensitive; + } + + private boolean isRegexEnabled() { + return regex; + } + + private void setCaseSensitive(final boolean b) { + caseSensitive = b; + searchField.update(); + } + + private void setRegexEnabled(final boolean b) { + regex = b; + searchField.update(); + } + + private class SearchField extends TextEditor.TextFieldWithPlaceholder { + + private static final long serialVersionUID = 7004232238240585434L; + private static final String REGEX_HOLDER = "[?*]"; + private static final String CASE_HOLDER = "[Aa]"; + private static final String DEF_HOLDER = "File filter... "; + + SearchField() { + try { + // make sure pane is large enough to display placeholders + final FontMetrics fm = getFontMetrics(getFont()); + final FontRenderContext frc = fm.getFontRenderContext(); + final String buf = CASE_HOLDER + REGEX_HOLDER + DEF_HOLDER; + final Rectangle2D rect = getFont().getStringBounds(buf, frc); + final int prefWidth = (int) rect.getWidth(); + setColumns(prefWidth / getColumnWidth()); + } catch (final Exception ignored) { + // do nothing + } + } + + void update() { + update(getGraphics()); + } + + @Override + String getPlaceholder() { + final StringBuilder sb = new StringBuilder(DEF_HOLDER); + if (isCaseSensitive()) + sb.append(CASE_HOLDER); + if (isRegexEnabled()) + sb.append(REGEX_HOLDER); + return sb.toString(); + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/FindAndReplaceDialog.java b/src/main/java/org/scijava/ui/swing/script/FindAndReplaceDialog.java index 26dd96e4..e1d24e1e 100644 --- a/src/main/java/org/scijava/ui/swing/script/FindAndReplaceDialog.java +++ b/src/main/java/org/scijava/ui/swing/script/FindAndReplaceDialog.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -44,6 +44,7 @@ import javax.swing.JLabel; import javax.swing.JOptionPane; import javax.swing.JPanel; +import javax.swing.JTextArea; import javax.swing.JTextField; import javax.swing.WindowConstants; @@ -63,6 +64,7 @@ public class FindAndReplaceDialog extends JDialog implements ActionListener { JLabel replaceLabel; JCheckBox matchCase, wholeWord, markAll, regex, forward; JButton findNext, replace, replaceAll, cancel; + private boolean restrictToConsole; public FindAndReplaceDialog(final TextEditor editor) { super(editor); @@ -80,8 +82,8 @@ public FindAndReplaceDialog(final TextEditor editor) { c.ipadx = c.ipady = 1; c.fill = GridBagConstraints.HORIZONTAL; c.anchor = GridBagConstraints.LINE_START; - searchField = createField("Find Next", text, c, null); - replaceField = createField("Replace with", text, c, this); + searchField = createField("Find: ", text, c, null); + replaceField = createField("Replace with: ", text, c, this); c.gridwidth = 4; c.gridheight = c.gridy; @@ -95,14 +97,14 @@ public FindAndReplaceDialog(final TextEditor editor) { c.gridwidth = 1; c.gridheight = 1; c.weightx = 0.001; - matchCase = createCheckBox("Match Case", root, c); + matchCase = createCheckBox("Match case", root, c); regex = createCheckBox("Regex", root, c); forward = createCheckBox("Search forward", root, c); forward.setSelected(true); c.gridx = 0; c.gridy++; - markAll = createCheckBox("Mark All", root, c); - wholeWord = createCheckBox("Whole Word", root, c); + markAll = createCheckBox("Mark all", root, c); + wholeWord = createCheckBox("Whole word", root, c); c.gridx = 4; c.gridy = 0; @@ -131,24 +133,41 @@ public void keyPressed(final KeyEvent e) { replaceField.addKeyListener(listener); } - protected RSyntaxTextArea getTextArea() { - return textEditor.getTextArea(); + private JTextArea getSearchArea() { + if (restrictToConsole) { + if (textEditor.getTab().showingErrors) + return textEditor.getErrorScreen(); + else + return textEditor.getTab().getScreen(); + } + return getTextArea(); + } + + final private EditorPane getTextAreaAsEditorPane() { + return textEditor.getEditorPane(); } + final public RSyntaxTextArea getTextArea() { + return getTextAreaAsEditorPane(); + } + + @SuppressWarnings("deprecation") @Override public void show(final boolean replace) { - setTitle(replace ? "Replace" : "Find"); + if (replace && restrictToConsole) + throw new IllegalArgumentException("replace is not compatible with restrictToConsole"); + updateTitle(); replaceLabel.setEnabled(replace); replaceField.setEnabled(replace); replaceField.setBackground(replace ? searchField.getBackground() : getRootPane().getBackground()); this.replace.setEnabled(replace); replaceAll.setEnabled(replace); - + markAll.setEnabled(!restrictToConsole); searchField.selectAll(); replaceField.selectAll(); getRootPane().setDefaultButton(findNext); - show(); + show(); // cannot call setVisible } private JTextField createField(final String name, final Container container, @@ -199,6 +218,10 @@ public void actionPerformed(final ActionEvent e) { if (source == findNext) searchOrReplace(false); else if (source == replace) searchOrReplace(true); else if (source == replaceAll) { + if (getTextAreaAsEditorPane().isLocked()) { + JOptionPane.showMessageDialog(this, "File is currently locked."); + return; + } final int replace = SearchEngine.replaceAll(getTextArea(), getSearchContext(true)) .getCount(); @@ -210,9 +233,24 @@ public boolean searchOrReplace(final boolean replace) { return searchOrReplace(replace, forward.isSelected()); } + public void setRestrictToConsole(final boolean restrict) { + restrictToConsole = restrict; + markAll.setEnabled(!restrict); + updateTitle(); + } + + private void updateTitle() { + String title = "Find"; + if (isReplace()) + title +="/Replace"; + if (restrictToConsole) + title += " in Console"; + setTitle(title); + } + public boolean searchOrReplace(final boolean replace, final boolean forward) { if (searchOrReplaceFromHere(replace, forward)) return true; - final RSyntaxTextArea textArea = getTextArea(); + final JTextArea textArea = getSearchArea(); final int caret = textArea.getCaretPosition(); textArea.setCaretPosition(forward ? 0 : textArea.getDocument().getLength()); if (searchOrReplaceFromHere(replace, forward)) return true; @@ -233,20 +271,22 @@ protected SearchContext getSearchContext(final boolean forward) { context.setMatchCase(matchCase.isSelected()); context.setWholeWord(wholeWord.isSelected()); context.setRegularExpression(regex.isSelected()); + context.setMarkAll(markAll.isSelected() && !restrictToConsole); return context; } protected boolean searchOrReplaceFromHere(final boolean replace, final boolean forward) { - final RSyntaxTextArea textArea = getTextArea(); final SearchContext context = getSearchContext(forward); - return (replace ? SearchEngine.replace(textArea, context) : SearchEngine - .find(textArea, context)).wasFound(); + if (replace) { + return SearchEngine.replace(getTextArea(), context).wasFound(); + } + return SearchEngine.find(getSearchArea(), context).wasFound(); } public boolean isReplace() { - return replace.isEnabled(); + return (restrictToConsole) ? false : replace.isEnabled(); } /** diff --git a/src/main/java/org/scijava/ui/swing/script/GutterUtils.java b/src/main/java/org/scijava/ui/swing/script/GutterUtils.java new file mode 100644 index 00000000..2d037de7 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/GutterUtils.java @@ -0,0 +1,146 @@ +/*- + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.ui.swing.script; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; + +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.text.BadLocationException; + +import org.fife.ui.rtextarea.FoldIndicator; +import org.fife.ui.rtextarea.Gutter; +import org.fife.ui.rtextarea.GutterIconInfo; + +public class GutterUtils { + + private final Gutter gutter; + + GutterUtils(final Gutter gutter) { + this.gutter = gutter; + gutter.setSpacingBetweenLineNumbersAndFoldIndicator(0); + //gutter.setFoldIndicatorStyle(org.fife.ui.rtextarea.FoldIndicatorStyle.MODERN); // DEFAULT + gutter.setShowCollapsedRegionToolTips(true); + } + + @SuppressWarnings("unused") + private void updateFoldIcons() { // no longer needed since v3.3.0 + int size; + try { + size = (int) new FoldIndicator(null).getPreferredSize().getWidth(); + } catch (final Exception | Error ignored) { + size = 12; // FoldIndicator#WIDTH + } + if (size < 8) + size = 8; // the default foldicon size in FoldIndicator + final int fontSize = gutter.getLineNumberFont().getSize(); + if (size > fontSize) + size = fontSize; + //gutter.setFoldIcons(new FoldIcon(true, size), new FoldIcon(false, size)); + } + + private ImageIcon getBookmarkIcon() { + final int size = gutter.getLineNumberFont().getSize(); + final BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + final Graphics2D graphics = image.createGraphics(); + graphics.setColor(gutter.getLineNumberColor()); + graphics.fillRect(0, 0, size, size); + graphics.setXORMode(gutter.getBorderColor()); + graphics.drawRect(0, 0, size - 1, size - 1); + image.flush(); + return new ImageIcon(image); + } + + private void updateBookmarkIcon() { + // this will clear existing bookmarks, so we'll need restore existing ones + final GutterIconInfo[] stash = gutter.getBookmarks(); + gutter.setBookmarkIcon(getBookmarkIcon()); + + for (final GutterIconInfo info : stash) { + try { + gutter.toggleBookmark(info.getMarkedOffset()); + } catch (final BadLocationException ignored) { + // do nothing + } + } + } + + public static void updateIcons(final Gutter gutter) { + final GutterUtils utils = new GutterUtils(gutter); + //utils.updateFoldIcons(); + utils.updateBookmarkIcon(); + } + + @SuppressWarnings("unused") + private class FoldIcon implements Icon { + + private final boolean collapsed; + private final Color background; + private final Color foreground; + private final int size; + + FoldIcon(final boolean collapsed, final int size) { + this.collapsed = collapsed; + this.background = gutter.getBorderColor(); + this.foreground = gutter.getActiveLineRangeColor(); + this.size = size; + } + + @Override + public int getIconHeight() { + return size; + } + + @Override + public int getIconWidth() { + return size; + } + + @Override + public void paintIcon(final Component c, final Graphics g, final int x, final int y) { + // the default '+'/'-' symbols are to small with scaled fonts, and we don't + // have an easy way to resize the space allocated for the icon in the gutter + if (collapsed) { + g.setColor(foreground); + g.fillRect(x, y, size, size); + g.setColor(background); + g.drawRect(x, y, size-2, size-1); // no idea why the extra pixel is needed + } else { + g.setColor(background); + g.fillRect(x, y, size, size); + g.setColor(foreground); + g.drawRect(x, y, size-2, size-1); + } + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java b/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java index 6a053080..cc78bfc9 100644 --- a/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java +++ b/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -30,8 +30,6 @@ package org.scijava.ui.swing.script; import java.awt.Dimension; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; import java.io.IOException; import java.io.PrintWriter; import java.io.Writer; @@ -75,15 +73,30 @@ public class InterpreterPane implements UIComponent { /** * Constructs an interpreter UI pane for a SciJava scripting REPL. * - * @param context The SciJava application context to use + * @param context The SciJava application context to use. */ public InterpreterPane(final Context context) { + this(context, null); + } + + /** + * Constructs an interpreter UI pane for a SciJava scripting REPL, with a + * given language preference. + * + * @param context The SciJava application context to use. + * @param languagePreference The given language to use, or null to fall back + * to the default. + */ + public InterpreterPane(final Context context, + final String languagePreference) + { context.inject(this); output = new OutputPane(log); final JScrollPane outputScroll = new JScrollPane(output); outputScroll.setPreferredSize(new Dimension(440, 400)); - repl = new ScriptREPL(context, output.getOutputStream()); + repl = new ScriptREPL(context, languagePreference, // + output.getOutputStream()); repl.initialize(); final Writer writer = output.getOutputWriter(); diff --git a/src/main/java/org/scijava/ui/swing/script/InterpreterWindow.java b/src/main/java/org/scijava/ui/swing/script/InterpreterWindow.java index 92c4c7f9..748687f3 100644 --- a/src/main/java/org/scijava/ui/swing/script/InterpreterWindow.java +++ b/src/main/java/org/scijava/ui/swing/script/InterpreterWindow.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -59,12 +59,29 @@ public class InterpreterWindow extends JFrame { @Parameter private LogService log; - /** Constructs the scripting interpreter window. */ + /** + * Constructs the scripting interpreter window. + * + * @param context The SciJava application context to use. + */ public InterpreterWindow(final Context context) { + this(context, null); + } + + /** + * Constructs the scripting interpreter window. + * + * @param context The SciJava application context to use. + * @param languagePreference The given language to use, or null to fall back + * to the default. + */ + public InterpreterWindow(final Context context, + final String languagePreference) + { super("Script Interpreter"); context.inject(this); - pane = new InterpreterPane(context) { + pane = new InterpreterPane(context, languagePreference) { @Override public void dispose() { super.dispose(); diff --git a/src/main/java/org/scijava/ui/swing/script/JTextAreaOutputStream.java b/src/main/java/org/scijava/ui/swing/script/JTextAreaOutputStream.java index 49429ec8..93a854dc 100644 --- a/src/main/java/org/scijava/ui/swing/script/JTextAreaOutputStream.java +++ b/src/main/java/org/scijava/ui/swing/script/JTextAreaOutputStream.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/JTextAreaWriter.java b/src/main/java/org/scijava/ui/swing/script/JTextAreaWriter.java index a49cbdc3..76a27fbe 100644 --- a/src/main/java/org/scijava/ui/swing/script/JTextAreaWriter.java +++ b/src/main/java/org/scijava/ui/swing/script/JTextAreaWriter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/LanguageSupportPlugin.java b/src/main/java/org/scijava/ui/swing/script/LanguageSupportPlugin.java index 159d47dd..5cf8125e 100644 --- a/src/main/java/org/scijava/ui/swing/script/LanguageSupportPlugin.java +++ b/src/main/java/org/scijava/ui/swing/script/LanguageSupportPlugin.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/LanguageSupportService.java b/src/main/java/org/scijava/ui/swing/script/LanguageSupportService.java index c6347f75..08ff251d 100644 --- a/src/main/java/org/scijava/ui/swing/script/LanguageSupportService.java +++ b/src/main/java/org/scijava/ui/swing/script/LanguageSupportService.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/MacroFunctions.java b/src/main/java/org/scijava/ui/swing/script/MacroFunctions.java index c7f0ccc0..7cf1b6ff 100644 --- a/src/main/java/org/scijava/ui/swing/script/MacroFunctions.java +++ b/src/main/java/org/scijava/ui/swing/script/MacroFunctions.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/AutoCompletionListener.java b/src/main/java/org/scijava/ui/swing/script/Main.java similarity index 50% rename from src/main/java/org/scijava/ui/swing/script/autocompletion/AutoCompletionListener.java rename to src/main/java/org/scijava/ui/swing/script/Main.java index 8fe52bf2..d753e0cf 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/AutoCompletionListener.java +++ b/src/main/java/org/scijava/ui/swing/script/Main.java @@ -1,8 +1,8 @@ -/*- +/* * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,19 +26,50 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.ui.swing.script.autocompletion; -import java.util.List; +package org.scijava.ui.swing.script; -import org.fife.ui.autocomplete.Completion; -import org.fife.ui.autocomplete.CompletionProvider; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; -public interface AutoCompletionListener { +import javax.swing.SwingUtilities; - /** - * @param codeWithoutLastLine The entire script up to the line with the caret. - * @param text The whole line up to the caret where autocompletion was invoked. - * @return - */ - public List completionsFor(final CompletionProvider provider, final String codeWithoutLastLine, final String lastLine, final String alreadyEnteredText); +import org.scijava.Context; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.ScriptService; + +import com.formdev.flatlaf.FlatDarkLaf; +import com.formdev.flatlaf.FlatLightLaf; + +/** + * Main entry point for launching the script editor standalone. + * + * @author Johannes Schindelin + * @author Curtis Rueden + */ +public final class Main { + + public static void launch(final String language) { + final Context context = new Context(); + final TextEditor editor = new TextEditor(context); + final ScriptService scriptService = context.getService(ScriptService.class); + final ScriptLanguage lang = scriptService.getLanguageByName(language); + if (lang == null) { + throw new IllegalArgumentException("Script language '" + language + + "' not found"); + } + editor.setLanguage(lang); + editor.addWindowListener(new WindowAdapter() { + @Override + public void windowClosed(final WindowEvent e) { + SwingUtilities.invokeLater(() -> context.dispose()); + } + }); + editor.setVisible(true); + } + public static void main(String[] args) throws Exception { + FlatDarkLaf.setup(); + String lang = args.length == 0 ? "Java" : args[0]; + launch(lang); + } } diff --git a/src/main/java/org/scijava/ui/swing/script/OutlineTreePanel.java b/src/main/java/org/scijava/ui/swing/script/OutlineTreePanel.java new file mode 100644 index 00000000..770bde71 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/OutlineTreePanel.java @@ -0,0 +1,189 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Insets; +import java.awt.RenderingHints; +import java.awt.font.FontRenderContext; +import java.awt.font.LineBreakMeasurer; +import java.awt.font.TextAttribute; +import java.awt.font.TextLayout; +import java.text.AttributedCharacterIterator; +import java.text.AttributedString; + +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JTree; +import javax.swing.tree.TreeNode; + +import org.fife.rsta.ac.AbstractSourceTree; +import org.fife.rsta.ac.java.tree.JavaOutlineTree; +import org.fife.rsta.ac.js.tree.JavaScriptOutlineTree; +import org.fife.rsta.ac.xml.tree.XmlOutlineTree; +import org.scijava.script.ScriptLanguage; + +/** + * Convenience class for displaying a {@link AbstractSourceTree} + * + * @author Tiago Ferreira + */ +class OutlineTreePanel extends JScrollPane { + + private static final long serialVersionUID = -710040159139542578L; + private AbstractSourceTree sourceTree; + private final Color placeholdColor; + private float fontSize; + private boolean sorted; + private boolean major; + + OutlineTreePanel() { + super(); + fontSize = getFont().getSize(); + setViewportView(new UnsupportedLangTree()); + placeholdColor = TextEditor.GuiUtils.getDisabledComponentColor(); + } + + protected void rebuildSourceTree(final EditorPane pane) { + if (!isVisible()) + return; + if (sourceTree != null) { + sourceTree.uninstall(); + } + final ScriptLanguage sLanguage = pane.getCurrentLanguage(); + final String language = (sLanguage == null) ? "" : sLanguage.getLanguageName(); + switch (language) { + case "Java": + //case "BeanShell": + sourceTree = new JavaOutlineTree(sorted); + break; + case "JavaScript": + sourceTree = new JavaScriptOutlineTree(sorted); + break; + default: + if (EditorPane.SYNTAX_STYLE_XML.equals(pane.getSyntaxEditingStyle())) { + sourceTree = new XmlOutlineTree(sorted); + } else + sourceTree = null; + break; + } + fontSize = pane.getFontSize(); + if (sourceTree == null) { + setViewportView(new UnsupportedLangTree(pane)); + } else { + sourceTree.setShowMajorElementsOnly(major); + sourceTree.setFont(sourceTree.getFont().deriveFont(fontSize)); + sourceTree.listenTo(pane); + setViewportView(sourceTree); + setPopupMenu(sourceTree, pane); + } + revalidate(); + } + + private void setPopupMenu(final JTree tree, final EditorPane pane) { + final JPopupMenu popup = new JPopupMenu(); + JMenuItem jmi; + if (tree instanceof AbstractSourceTree) { + jmi = new JMenuItem("Collapse All"); + jmi.addActionListener(e -> TextEditor.GuiUtils.collapseAllTreeNodes(tree)); + popup.add(jmi); + jmi = new JMenuItem("Expand All"); + jmi.addActionListener(e -> TextEditor.GuiUtils.expandAllTreeNodes(tree)); + popup.add(jmi); + popup.addSeparator(); + final JCheckBoxMenuItem jcmi1 = new JCheckBoxMenuItem("Hide 'Minor' Elements", major); + jcmi1.setToolTipText("Whether non-proeminent elements (e.g., local variables) should be displayed"); + jcmi1.addItemListener(e -> { + major = jcmi1.isSelected(); + ((AbstractSourceTree) tree).setShowMajorElementsOnly(major); + ((AbstractSourceTree) tree).refresh(); + }); + popup.add(jcmi1); + final JCheckBoxMenuItem jcmi2 = new JCheckBoxMenuItem("Sort Elements", sorted); + jcmi2.addItemListener(e -> { + sorted = jcmi2.isSelected(); + ((AbstractSourceTree) tree).setSorted(sorted); // will refresh + }); + popup.add(jcmi2); + popup.addSeparator(); + } + jmi = new JMenuItem("Rebuild"); + jmi.addActionListener(e -> rebuildSourceTree(pane)); + popup.add(jmi); + tree.setComponentPopupMenu(popup); + } + + private class UnsupportedLangTree extends JTree { + + private static final long serialVersionUID = 1L; + private static final String HOLDER = "Outline not available... " + + "(Currently, only Java, JS, & XML are supported)"; + + public UnsupportedLangTree() { + super((TreeNode) null); + } + + public UnsupportedLangTree(final EditorPane pane) { + this(); + setPopupMenu(this, pane); + } + + @Override + protected void paintComponent(final java.awt.Graphics g) { + super.paintComponent(g); + final Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setColor(placeholdColor); + final Insets i = getInsets(); + float yTop = i.top; + final int w = getWidth() - i.left - i.right; + final int lastIndex = HOLDER.length(); + final AttributedString ac = new AttributedString(HOLDER); + ac.addAttribute(TextAttribute.SIZE, fontSize, 0, lastIndex); + ac.addAttribute(TextAttribute.POSTURE, TextAttribute.POSTURE_OBLIQUE, 0, lastIndex); + final AttributedCharacterIterator aci = ac.getIterator(); + final FontRenderContext frc = g2.getFontRenderContext(); + final LineBreakMeasurer lbm = new LineBreakMeasurer(aci, frc); + while (lbm.getPosition() < aci.getEndIndex()) { + // see https://stackoverflow.com/a/41118280s + final TextLayout tl = lbm.nextLayout(w); + final float xPos = (float) (i.left + ((getWidth() - tl.getBounds().getWidth()) / 2)); + final float yPos = (float) (yTop + ((getHeight() - tl.getBounds().getHeight()) / 2)); + tl.draw(g2, xPos, yPos + tl.getAscent()); + yTop += tl.getDescent() + tl.getLeading() + tl.getAscent(); + } + g2.dispose(); + } + + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/OutputPane.java b/src/main/java/org/scijava/ui/swing/script/OutputPane.java index 728e8500..5d1de26b 100644 --- a/src/main/java/org/scijava/ui/swing/script/OutputPane.java +++ b/src/main/java/org/scijava/ui/swing/script/OutputPane.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -50,8 +50,7 @@ public class OutputPane extends JTextArea { public OutputPane(final LogService log) { this.log = log; - final Font font = new Font("Courier", Font.PLAIN, 12); - setFont(font); + setFont(new Font(Font.MONOSPACED, Font.PLAIN, getFont().getSize())); setEditable(false); setFocusable(true); setLineWrap(true); diff --git a/src/main/java/org/scijava/ui/swing/script/PromptPane.java b/src/main/java/org/scijava/ui/swing/script/PromptPane.java index 00df6cbc..752ce76d 100644 --- a/src/main/java/org/scijava/ui/swing/script/PromptPane.java +++ b/src/main/java/org/scijava/ui/swing/script/PromptPane.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/RecentFilesMenuItem.java b/src/main/java/org/scijava/ui/swing/script/RecentFilesMenuItem.java index b177a945..97038866 100644 --- a/src/main/java/org/scijava/ui/swing/script/RecentFilesMenuItem.java +++ b/src/main/java/org/scijava/ui/swing/script/RecentFilesMenuItem.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/ScriptEditor.java b/src/main/java/org/scijava/ui/swing/script/ScriptEditor.java index 2d65a4fc..d1415ff2 100644 --- a/src/main/java/org/scijava/ui/swing/script/ScriptEditor.java +++ b/src/main/java/org/scijava/ui/swing/script/ScriptEditor.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/ScriptInterpreterPlugin.java b/src/main/java/org/scijava/ui/swing/script/ScriptInterpreterPlugin.java index 39c759b2..4dc061aa 100644 --- a/src/main/java/org/scijava/ui/swing/script/ScriptInterpreterPlugin.java +++ b/src/main/java/org/scijava/ui/swing/script/ScriptInterpreterPlugin.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/SyntaxHighlighter.java b/src/main/java/org/scijava/ui/swing/script/SyntaxHighlighter.java index 1a0691a6..c4574c1d 100644 --- a/src/main/java/org/scijava/ui/swing/script/SyntaxHighlighter.java +++ b/src/main/java/org/scijava/ui/swing/script/SyntaxHighlighter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/TextEditor.java b/src/main/java/org/scijava/ui/swing/script/TextEditor.java index b4996891..7c74fb3b 100644 --- a/src/main/java/org/scijava/ui/swing/script/TextEditor.java +++ b/src/main/java/org/scijava/ui/swing/script/TextEditor.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -30,11 +30,15 @@ package org.scijava.ui.swing.script; import java.awt.Color; +import java.awt.Component; import java.awt.Cursor; +import java.awt.Desktop; import java.awt.Dimension; import java.awt.Font; +import java.awt.Graphics2D; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; +import java.awt.RenderingHints; import java.awt.Toolkit; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.Transferable; @@ -51,8 +55,6 @@ import java.awt.event.ActionListener; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; -import java.awt.event.FocusEvent; -import java.awt.event.FocusListener; import java.awt.event.ItemEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; @@ -76,64 +78,56 @@ import java.io.StringReader; import java.io.Writer; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.TreeMap; import java.util.Vector; import java.util.concurrent.ExecutionException; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import java.util.zip.ZipException; import javax.script.ScriptEngine; import javax.script.ScriptException; -import javax.swing.AbstractAction; -import javax.swing.BorderFactory; -import javax.swing.BoxLayout; -import javax.swing.ButtonGroup; -import javax.swing.JButton; -import javax.swing.JCheckBoxMenuItem; -import javax.swing.JFileChooser; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.JMenu; -import javax.swing.JMenuBar; -import javax.swing.JMenuItem; -import javax.swing.JOptionPane; -import javax.swing.JPanel; -import javax.swing.JRadioButtonMenuItem; -import javax.swing.JScrollPane; -import javax.swing.JSplitPane; -import javax.swing.JTabbedPane; -import javax.swing.JTextArea; -import javax.swing.JTextField; -import javax.swing.KeyStroke; -import javax.swing.SwingUtilities; +import javax.swing.*; +import javax.swing.border.BevelBorder; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultHighlighter; +import javax.swing.text.DefaultHighlighter.DefaultHighlightPainter; import javax.swing.text.Position; import javax.swing.tree.TreePath; import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.Theme; import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; +import org.fife.ui.rtextarea.ClipboardHistory; +import org.fife.ui.rtextarea.Macro; +import org.fife.ui.rtextarea.RTextArea; +import org.fife.ui.rtextarea.RTextAreaEditorKit; +import org.fife.ui.rtextarea.RTextAreaEditorKit.SetReadOnlyAction; +import org.fife.ui.rtextarea.RTextAreaEditorKit.SetWritableAction; import org.scijava.Context; import org.scijava.app.AppService; import org.scijava.batch.BatchService; @@ -144,6 +138,7 @@ import org.scijava.log.LogService; import org.scijava.module.ModuleException; import org.scijava.module.ModuleService; +import org.scijava.options.OptionsService; import org.scijava.platform.PlatformService; import org.scijava.plugin.Parameter; import org.scijava.plugin.PluginInfo; @@ -166,9 +161,12 @@ import org.scijava.util.FileUtils; import org.scijava.util.MiscUtils; import org.scijava.util.POM; +import org.scijava.util.PlatformUtils; import org.scijava.util.Types; import org.scijava.widget.FileWidget; +import com.formdev.flatlaf.FlatLaf; + /** * A versatile script editor for SciJava applications. *

@@ -181,12 +179,15 @@ * * @author Johannes Schindelin * @author Jonathan Hale + * @author Albert Cardona + * @author Tiago Ferreira */ public class TextEditor extends JFrame implements ActionListener, ChangeListener, CloseConfirmable, DocumentListener { private static final Set TEMPLATE_PATHS = new HashSet<>(); +// private static final int BORDER_SIZE = 4; public static final String AUTO_IMPORT_PREFS = "script.editor.AutoImport"; public static final String WINDOW_HEIGHT = "script.editor.height"; public static final String WINDOW_WIDTH = "script.editor.width"; @@ -195,6 +196,7 @@ public class TextEditor extends JFrame implements ActionListener, public static final String MAIN_DIV_LOCATION = "script.editor.main.divLocation"; public static final String TAB_DIV_LOCATION = "script.editor.tab.divLocation"; public static final String TAB_DIV_ORIENTATION = "script.editor.tab.divOrientation"; + public static final String REPL_DIV_LOCATION = "script.editor.repl.divLocation"; public static final String LAST_LANGUAGE = "script.editor.lastLanguage"; static { @@ -209,25 +211,27 @@ public class TextEditor extends JFrame implements ActionListener, private JTabbedPane tabbed; private JMenuItem newFile, open, save, saveas, compileAndRun, compile, - close, undo, redo, cut, copy, paste, find, replace, selectAll, kill, + close, undo, redo, cut, copy, paste, find, selectAll, kill, gotoLine, makeJar, makeJarWithSource, removeUnusedImports, sortImports, removeTrailingWhitespace, findNext, findPrevious, openHelp, addImport, - clearScreen, nextError, previousError, openHelpWithoutFrames, nextTab, - previousTab, runSelection, extractSourceJar, toggleBookmark, - listBookmarks, openSourceForClass, openSourceForMenuItem, + nextError, previousError, openHelpWithoutFrames, nextTab, + previousTab, runSelection, extractSourceJar, openSourceForClass, + //openSourceForMenuItem, // this never had an actionListener!?? openMacroFunctions, decreaseFontSize, increaseFontSize, chooseFontSize, - chooseTabSize, gitGrep, openInGitweb, replaceTabsWithSpaces, - replaceSpacesWithTabs, toggleWhiteSpaceLabeling, zapGremlins, - savePreferences, toggleAutoCompletionMenu, openClassOrPackageHelp; + chooseTabSize, gitGrep, replaceTabsWithSpaces, + replaceSpacesWithTabs, zapGremlins,openClassOrPackageHelp; private RecentFilesMenuItem openRecent; - private JMenu gitMenu, tabsMenu, fontSizeMenu, tabSizeMenu, toolsMenu, - runMenu, whiteSpaceMenu; + private JMenu editMenu, gitMenu, tabsMenu, fontSizeMenu, tabSizeMenu, toolsMenu, + runMenu; private int tabsMenuTabsStart; private Set tabsMenuItems; private FindAndReplaceDialog findDialog; - private JCheckBoxMenuItem autoSave, wrapLines, tabsEmulated, autoImport; + private JCheckBoxMenuItem autoSave, wrapLines, tabsEmulated, autoImport, + autocompletion, fallbackAutocompletion, keylessAutocompletion, + markOccurences, paintTabs, whiteSpace, marginLine, lockPane; + private ButtonGroup themeRadioGroup; private JTextArea errorScreen = new JTextArea(); - + private final FileSystemTree tree; private final JSplitPane body; @@ -236,6 +240,9 @@ public class TextEditor extends JFrame implements ActionListener, private ErrorHandler errorHandler; private boolean respectAutoImports; + private String activeTheme; + private int[] panePositions; + @Parameter private Context context; @@ -265,6 +272,8 @@ public class TextEditor extends JFrame implements ActionListener, private AppService appService; @Parameter private BatchService batchService; + @Parameter(required = false) + private OptionsService optionsService; private Map languageMenuItems; private JRadioButtonMenuItem noneLanguageItem; @@ -274,17 +283,31 @@ public class TextEditor extends JFrame implements ActionListener, private boolean incremental = false; private DragSource dragSource; private boolean layoutLoading = true; - + private OutlineTreePanel sourceTreePanel; + protected final CommandPalette cmdPalette; + public static final ArrayList instances = new ArrayList<>(); public static final ArrayList contexts = new ArrayList<>(); public TextEditor(final Context context) { + super("Script Editor"); instances.add(this); contexts.add(context); context.inject(this); initializeTokenMakers(); + // NB: All panes must be initialized before menus are assembled! + tabbed = GuiUtils.getJTabbedPane(); + tree = new FileSystemTree(log); + final JTabbedPane sideTabs = GuiUtils.getJTabbedPane(); + sideTabs.addTab("File Explorer", new FileSystemTreePanel(tree, context)); + sideTabs.addTab("Outline", sourceTreePanel = new OutlineTreePanel()); + body = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sideTabs, tabbed); + + // These items are dynamic and need to be initialized before EditorPane creation + initializeDynamicMenuComponents(); + // -- BEGIN MENUS -- // Initialize menu @@ -304,94 +327,69 @@ public TextEditor(final Context context) { openRecent = new RecentFilesMenuItem(prefService, this); openRecent.setMnemonic(KeyEvent.VK_R); file.add(openRecent); + file.addSeparator(); save = addToMenu(file, "Save", KeyEvent.VK_S, ctrl); save.setMnemonic(KeyEvent.VK_S); - saveas = addToMenu(file, "Save as...", 0, 0); + saveas = addToMenu(file, "Save As...", KeyEvent.VK_S, ctrl + shift); saveas.setMnemonic(KeyEvent.VK_A); file.addSeparator(); - makeJar = addToMenu(file, "Export as .jar", 0, 0); + makeJar = addToMenu(file, "Export as JAR...", 0, 0); makeJar.setMnemonic(KeyEvent.VK_E); - makeJarWithSource = addToMenu(file, "Export as .jar (with source)", 0, 0); + makeJarWithSource = addToMenu(file, "Export as JAR (With Source)...", 0, 0); makeJarWithSource.setMnemonic(KeyEvent.VK_X); file.addSeparator(); + lockPane = new JCheckBoxMenuItem("Lock (Make Read Only)"); + lockPane.setToolTipText("Protects file from accidental editing"); + file.add(lockPane); + lockPane.addActionListener(e -> { + if (lockPane.isSelected()) { + new SetReadOnlyAction().actionPerformedImpl(e, getEditorPane()); + } else { + new SetWritableAction().actionPerformedImpl(e, getEditorPane()); + } + }); + JMenuItem jmi = new JMenuItem("Revert..."); + jmi.addActionListener(e -> { + if (getEditorPane().isLocked()) { + error("File is currently locked."); + return; + } + final File f = getEditorPane().getFile(); + if (f == null || !f.exists()) { + error(getEditorPane().getFileName() + "\nhas not been saved or its file is not available."); + } else { + reloadRevert("Revert to Saved File? Any unsaved changes will be lost.", "Revert"); + } + }); + file.add(jmi); + file.addSeparator(); + jmi = new JMenuItem("Show in System Explorer"); + jmi.addActionListener(e -> { + final File f = getEditorPane().getFile(); + if (f == null || !f.exists()) { + error(getEditorPane().getFileName() + "\nhas not been saved or its file is not available."); + } else { + try { + Desktop.getDesktop().open(f.getParentFile()); + } catch (final Exception | Error ignored) { + error(getEditorPane().getFileName() + "\ndoes not seem to be accessible."); + } + } + }); + file.add(jmi); + file.addSeparator(); close = addToMenu(file, "Close", KeyEvent.VK_W, ctrl); mbar.add(file); // -- Edit menu -- - - final JMenu edit = new JMenu("Edit"); - edit.setMnemonic(KeyEvent.VK_E); - undo = addToMenu(edit, "Undo", KeyEvent.VK_Z, ctrl); - redo = addToMenu(edit, "Redo", KeyEvent.VK_Y, ctrl); - edit.addSeparator(); - selectAll = addToMenu(edit, "Select All", KeyEvent.VK_A, ctrl); - cut = addToMenu(edit, "Cut", KeyEvent.VK_X, ctrl); - copy = addToMenu(edit, "Copy", KeyEvent.VK_C, ctrl); - paste = addToMenu(edit, "Paste", KeyEvent.VK_V, ctrl); - edit.addSeparator(); - find = addToMenu(edit, "Find...", KeyEvent.VK_F, ctrl); - find.setMnemonic(KeyEvent.VK_F); - findNext = addToMenu(edit, "Find Next", KeyEvent.VK_F3, 0); - findNext.setMnemonic(KeyEvent.VK_N); - findPrevious = addToMenu(edit, "Find Previous", KeyEvent.VK_F3, shift); - findPrevious.setMnemonic(KeyEvent.VK_P); - replace = addToMenu(edit, "Find and Replace...", KeyEvent.VK_H, ctrl); - gotoLine = addToMenu(edit, "Goto line...", KeyEvent.VK_G, ctrl); - gotoLine.setMnemonic(KeyEvent.VK_G); - toggleBookmark = addToMenu(edit, "Toggle Bookmark", KeyEvent.VK_B, ctrl); - toggleBookmark.setMnemonic(KeyEvent.VK_B); - listBookmarks = addToMenu(edit, "List Bookmarks", 0, 0); - listBookmarks.setMnemonic(KeyEvent.VK_O); - edit.addSeparator(); - - clearScreen = addToMenu(edit, "Clear output panel", 0, 0); - clearScreen.setMnemonic(KeyEvent.VK_L); - - zapGremlins = addToMenu(edit, "Zap Gremlins", 0, 0); - - edit.addSeparator(); - addImport = addToMenu(edit, "Add import...", 0, 0); - addImport.setMnemonic(KeyEvent.VK_I); - removeUnusedImports = addToMenu(edit, "Remove unused imports", 0, 0); - removeUnusedImports.setMnemonic(KeyEvent.VK_U); - sortImports = addToMenu(edit, "Sort imports", 0, 0); - sortImports.setMnemonic(KeyEvent.VK_S); - respectAutoImports = prefService.getBoolean(getClass(), AUTO_IMPORT_PREFS, false); - autoImport = - new JCheckBoxMenuItem("Auto-import (deprecated)", respectAutoImports); - autoImport.addItemListener(e -> { - respectAutoImports = e.getStateChange() == ItemEvent.SELECTED; - prefService.put(getClass(), AUTO_IMPORT_PREFS, respectAutoImports); - }); - edit.add(autoImport); - - whiteSpaceMenu = new JMenu("Whitespace"); - whiteSpaceMenu.setMnemonic(KeyEvent.VK_W); - removeTrailingWhitespace = - addToMenu(whiteSpaceMenu, "Remove trailing whitespace", 0, 0); - removeTrailingWhitespace.setMnemonic(KeyEvent.VK_W); - replaceTabsWithSpaces = - addToMenu(whiteSpaceMenu, "Replace tabs with spaces", 0, 0); - replaceTabsWithSpaces.setMnemonic(KeyEvent.VK_S); - replaceSpacesWithTabs = - addToMenu(whiteSpaceMenu, "Replace spaces with tabs", 0, 0); - replaceSpacesWithTabs.setMnemonic(KeyEvent.VK_T); - toggleWhiteSpaceLabeling = new JRadioButtonMenuItem("Label whitespace"); - toggleWhiteSpaceLabeling.setMnemonic(KeyEvent.VK_L); - toggleWhiteSpaceLabeling.addActionListener(e -> { - getTextArea().setWhitespaceVisible(toggleWhiteSpaceLabeling.isSelected()); - }); - whiteSpaceMenu.add(toggleWhiteSpaceLabeling); - - edit.add(whiteSpaceMenu); - - mbar.add(edit); + editMenu = new JMenu("Edit"); // cannot be populated here. see #assembleEditMenu() + editMenu.setMnemonic(KeyEvent.VK_E); + mbar.add(editMenu); // -- Language menu -- - languageMenuItems = - new LinkedHashMap<>(); + languageMenuItems = new LinkedHashMap<>(); final Set usedShortcuts = new HashSet<>(); final JMenu languages = new JMenu("Language"); languages.setMnemonic(KeyEvent.VK_L); @@ -455,12 +453,12 @@ public TextEditor(final Context context) { compileAndRun.setMnemonic(KeyEvent.VK_R); runSelection = - addToMenu(runMenu, "Run selected code", KeyEvent.VK_R, ctrl | shift); + addToMenu(runMenu, "Run Selected Code", KeyEvent.VK_R, ctrl | shift); runSelection.setMnemonic(KeyEvent.VK_S); compile = addToMenu(runMenu, "Compile", KeyEvent.VK_C, ctrl | shift); compile.setMnemonic(KeyEvent.VK_C); - autoSave = new JCheckBoxMenuItem("Auto-save before compiling"); + autoSave = new JCheckBoxMenuItem("Auto-save Before Compiling"); runMenu.add(autoSave); runMenu.addSeparator(); @@ -468,10 +466,12 @@ public TextEditor(final Context context) { nextError.setMnemonic(KeyEvent.VK_N); previousError = addToMenu(runMenu, "Previous Error", KeyEvent.VK_F4, shift); previousError.setMnemonic(KeyEvent.VK_P); - + final JMenuItem clearHighlights = new JMenuItem("Clear Marked Errors"); + clearHighlights.addActionListener(e -> getEditorPane().getErrorHighlighter().reset()); + runMenu.add(clearHighlights); runMenu.addSeparator(); - kill = addToMenu(runMenu, "Kill running script...", 0, 0); + kill = addToMenu(runMenu, "Kill Running Script...", 0, 0); kill.setMnemonic(KeyEvent.VK_K); kill.setEnabled(false); @@ -481,25 +481,39 @@ public TextEditor(final Context context) { toolsMenu = new JMenu("Tools"); toolsMenu.setMnemonic(KeyEvent.VK_O); - openHelpWithoutFrames = - addToMenu(toolsMenu, "Open Help for Class...", 0, 0); - openHelpWithoutFrames.setMnemonic(KeyEvent.VK_O); - openHelp = - addToMenu(toolsMenu, "Open Help for Class (with frames)...", 0, 0); - openHelp.setMnemonic(KeyEvent.VK_P); - openClassOrPackageHelp = addToMenu(toolsMenu, "Source or javadoc for class or package...", 0, 0); - openClassOrPackageHelp.setMnemonic(KeyEvent.VK_S); - openMacroFunctions = - addToMenu(toolsMenu, "Open Help on Macro Functions...", 0, 0); - openMacroFunctions.setMnemonic(KeyEvent.VK_H); - extractSourceJar = addToMenu(toolsMenu, "Extract source .jar...", 0, 0); + cmdPalette = new CommandPalette(this); + cmdPalette.install(toolsMenu); + + GuiUtils.addMenubarSeparator(toolsMenu, "Imports:"); + addImport = addToMenu(toolsMenu, "Add Import...", 0, 0); + addImport.setMnemonic(KeyEvent.VK_I); + respectAutoImports = prefService.getBoolean(getClass(), AUTO_IMPORT_PREFS, false); + autoImport = + new JCheckBoxMenuItem("Auto-import (Deprecated)", respectAutoImports); + autoImport.setToolTipText("Automatically imports common classes before running code"); + autoImport.addItemListener(e -> { + respectAutoImports = e.getStateChange() == ItemEvent.SELECTED; + prefService.put(getClass(), AUTO_IMPORT_PREFS, respectAutoImports); + if (respectAutoImports) + write("Auto-imports on. Lines associated with execution errors cannot be marked"); + else + write("Auto-imports off. Lines associated with execution errors can be marked"); + }); + toolsMenu.add(autoImport); + removeUnusedImports = addToMenu(toolsMenu, "Remove Unused Imports", 0, 0); + removeUnusedImports.setMnemonic(KeyEvent.VK_U); + sortImports = addToMenu(toolsMenu, "Sort Imports", 0, 0); + sortImports.setMnemonic(KeyEvent.VK_S); + + GuiUtils.addMenubarSeparator(toolsMenu, "Source & APIs:"); + extractSourceJar = addToMenu(toolsMenu, "Extract Source Jar...", 0, 0); extractSourceJar.setMnemonic(KeyEvent.VK_E); - openSourceForClass = - addToMenu(toolsMenu, "Open .java file for class...", 0, 0); + openSourceForClass = addToMenu(toolsMenu, "Open Java File for Class...", 0, 0); openSourceForClass.setMnemonic(KeyEvent.VK_J); - openSourceForMenuItem = - addToMenu(toolsMenu, "Open .java file for menu item...", 0, 0); - openSourceForMenuItem.setMnemonic(KeyEvent.VK_M); + //openSourceForMenuItem = addToMenu(toolsMenu, "Open Java File for Menu Item...", 0, 0); + //openSourceForMenuItem.setMnemonic(KeyEvent.VK_M); + + addScritpEditorMacroCommands(toolsMenu); mbar.add(toolsMenu); // -- Git menu -- @@ -516,14 +530,33 @@ public TextEditor(final Context context) { */ gitGrep = addToMenu(gitMenu, "Grep...", 0, 0); gitGrep.setMnemonic(KeyEvent.VK_G); - openInGitweb = addToMenu(gitMenu, "Open in gitweb", 0, 0); - openInGitweb.setMnemonic(KeyEvent.VK_W); mbar.add(gitMenu); - // -- Tabs menu -- - - tabsMenu = new JMenu("Tabs"); - tabsMenu.setMnemonic(KeyEvent.VK_A); + // -- Window Menu (previously labeled as Tabs menu -- + tabsMenu = new JMenu("Window"); + tabsMenu.setMnemonic(KeyEvent.VK_W); + GuiUtils.addMenubarSeparator(tabsMenu, "Panes:"); + // Assume initial status from prefs or panel visibility + final JCheckBoxMenuItem jcmi1 = new JCheckBoxMenuItem("Side Pane", + prefService.getInt(getClass(), MAIN_DIV_LOCATION, body.getDividerLocation()) > 0 + || isLeftPaneExpanded(body)); + jcmi1.addItemListener(e -> collapseSplitPane(0, !jcmi1.isSelected())); + tabsMenu.add(jcmi1); + // Console not initialized. Assume it is displayed if no prefs read + final JCheckBoxMenuItem jcmi2 = new JCheckBoxMenuItem("Console", + prefService.getInt(getClass(), TAB_DIV_LOCATION, 1) > 0); + jcmi2.addItemListener(e -> collapseSplitPane(1, !jcmi2.isSelected())); + tabsMenu.add(jcmi2); + final JMenuItem mi = new JMenuItem("Reset Layout..."); + mi.addActionListener(e -> { + if (confirm("Reset Location of Console and File Explorer?", "Reset Layout?", "Reset")) { + resetLayout(); + jcmi1.setSelected(true); + jcmi2.setSelected(true); + } + }); + tabsMenu.add(mi); + GuiUtils.addMenubarSeparator(tabsMenu, "Tabs:"); nextTab = addToMenu(tabsMenu, "Next Tab", KeyEvent.VK_PAGE_DOWN, ctrl); nextTab.setMnemonic(KeyEvent.VK_N); previousTab = @@ -540,14 +573,15 @@ public TextEditor(final Context context) { options.setMnemonic(KeyEvent.VK_O); // Font adjustments + GuiUtils.addMenubarSeparator(options, "Font:"); decreaseFontSize = - addToMenu(options, "Decrease font size", KeyEvent.VK_MINUS, ctrl); + addToMenu(options, "Decrease Font Size", KeyEvent.VK_MINUS, ctrl); decreaseFontSize.setMnemonic(KeyEvent.VK_D); increaseFontSize = - addToMenu(options, "Increase font size", KeyEvent.VK_PLUS, ctrl); + addToMenu(options, "Increase Font Size", KeyEvent.VK_PLUS, ctrl); increaseFontSize.setMnemonic(KeyEvent.VK_C); - fontSizeMenu = new JMenu("Font sizes"); + fontSizeMenu = new JMenu("Font Size"); fontSizeMenu.setMnemonic(KeyEvent.VK_Z); final boolean[] fontSizeShortcutUsed = new boolean[10]; final ButtonGroup buttonGroup = new ButtonGroup(); @@ -573,8 +607,12 @@ public TextEditor(final Context context) { fontSizeMenu.add(chooseFontSize); options.add(fontSizeMenu); - // Add tab size adjusting menu - tabSizeMenu = new JMenu("Tab sizes"); + GuiUtils.addMenubarSeparator(options, "Indentation:"); + tabsEmulated = new JCheckBoxMenuItem("Indent Using Spaces"); + tabsEmulated.setMnemonic(KeyEvent.VK_S); + tabsEmulated.addItemListener(e -> setTabsEmulated(tabsEmulated.getState())); + options.add(tabsEmulated); + tabSizeMenu = new JMenu("Tab Width"); tabSizeMenu.setMnemonic(KeyEvent.VK_T); final ButtonGroup bg = new ButtonGroup(); for (final int size : new int[] { 2, 4, 8 }) { @@ -593,51 +631,63 @@ public TextEditor(final Context context) { bg.add(chooseTabSize); tabSizeMenu.add(chooseTabSize); options.add(tabSizeMenu); - - wrapLines = new JCheckBoxMenuItem("Wrap lines"); - wrapLines.addChangeListener(e -> getEditorPane().setLineWrap(wrapLines.getState())); + replaceSpacesWithTabs = addToMenu(options, "Replace Spaces With Tabs", 0, 0); + replaceTabsWithSpaces = addToMenu(options, "Replace Tabs With Spaces", 0, 0); + + GuiUtils.addMenubarSeparator(options, "View:"); + options.add(markOccurences); + options.add(paintTabs); + options.add(marginLine); + options.add(whiteSpace); options.add(wrapLines); + options.add(applyThemeMenu()); - // Add Tab inserts as spaces - tabsEmulated = new JCheckBoxMenuItem("Tab key inserts spaces"); - tabsEmulated.addChangeListener(e -> getEditorPane().setTabsEmulated(tabsEmulated.getState())); - options.add(tabsEmulated); - - toggleAutoCompletionMenu = new JCheckBoxMenuItem("Auto completion"); - toggleAutoCompletionMenu.setSelected(prefService.getBoolean(TextEditor.class, "autoComplete", true)); - toggleAutoCompletionMenu.addChangeListener(e -> toggleAutoCompletion()); - options.add(toggleAutoCompletionMenu); + GuiUtils.addMenubarSeparator(options, "Code Completions:"); + options.add(autocompletion); + options.add(keylessAutocompletion); + options.add(fallbackAutocompletion); options.addSeparator(); - savePreferences = addToMenu(options, "Save Preferences", 0, 0); - + appendPreferences(options); mbar.add(options); + mbar.add(helpMenu()); // -- END MENUS -- - // Add the editor and output area - tabbed = new JTabbedPane(); + // add tab-related listeners tabbed.addChangeListener(this); + sideTabs.addChangeListener(e -> { + if (sideTabs.getSelectedIndex() == 1) + sourceTreePanel.rebuildSourceTree(getTab(tabbed.getSelectedIndex()).editorPane); + }); + + // Add the editor and output area + new FileDrop(tabbed, files -> { + final ArrayList filteredFiles = new ArrayList<>(); + assembleFlatFileCollection(filteredFiles, files); + if (filteredFiles.isEmpty()) { + warn("None of the dropped file(s) seems parseable."); + return; + } + if (filteredFiles.size() < 10 + || confirm("Confirm loading of " + filteredFiles.size() + " items?", "Confirm?", "Load")) { + filteredFiles.forEach(f -> open(f)); + } + }); open(null); // make sure the editor pane is added + getContentPane().setLayout(new BoxLayout(getContentPane(), BoxLayout.Y_AXIS)); - tabbed.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); - getContentPane().setLayout( - new BoxLayout(getContentPane(), BoxLayout.Y_AXIS)); - - final JPanel tree_panel = new JPanel(); - final JButton add_directory = new JButton("[+]"); - add_directory.setToolTipText("Add a directory"); - final JButton remove_directory = new JButton("[-]"); - remove_directory.setToolTipText("Remove a top-level directory"); - - final JTextField filter = new JTextField("filter..."); - filter.setForeground(Color.gray); - filter.setToolTipText("Use leading '/' for regular expressions"); - - tree = new FileSystemTree(log); - tree.ignoreExtension("class"); + // Tweaks for JSplitPane + body.setOneTouchExpandable(true); + body.addPropertyChangeListener(evt -> { + if ("dividerLocation".equals(evt.getPropertyName())) saveWindowSizeToPrefs(); + }); + + // Tweaks for FileSystemTree + tree.addTopLevelFoldersFrom(getEditorPane().loadFolders()); // Restore top-level directories dragSource = new DragSource(); dragSource.createDefaultDragGestureRecognizer(tree, DnDConstants.ACTION_COPY, new DragAndDrop()); + tree.ignoreExtension("class"); tree.setMinimumSize(new Dimension(200, 600)); tree.addLeafListener(f -> { final String name = f.getName(); @@ -656,149 +706,27 @@ public TextEditor(final Context context) { final Object o = ioService.open(f.getAbsolutePath()); // Open in whatever way possible if (null != o) uiService.show(o); - else JOptionPane.showMessageDialog(TextEditor.this, - "Could not open the file at: " + f.getAbsolutePath()); + else error("Could not open the file at\n" + f.getAbsolutePath()); return; } - catch (Exception e) { + catch (final Exception e) { log.error(e); - error("Could not open image at " + f); + error("Could not open file at\n" + f); } } // Ask: - final int choice = JOptionPane.showConfirmDialog(TextEditor.this, - "Really try to open file " + name + " in a tab?", "Confirm", - JOptionPane.OK_CANCEL_OPTION); - if (JOptionPane.OK_OPTION == choice) { + if (confirm("Really try to open file " + name + " in a tab?", "Confirm", "Open")) { open(f); } }); - add_directory.addActionListener(e -> { - final JFileChooser c = new JFileChooser("Choose a directory"); - c.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); - c.setFileHidingEnabled(true); // hide hidden files - if (JFileChooser.APPROVE_OPTION == c.showOpenDialog(getContentPane())) { - final File f = c.getSelectedFile(); - if (f.isDirectory()) tree.addRootDirectory(f.getAbsolutePath(), false); - } - }); - remove_directory.addActionListener(e -> { - final TreePath p = tree.getSelectionPath(); - if (null == p) { - JOptionPane.showMessageDialog(TextEditor.this, - "Select a top-level folder first."); - return; - } - if (2 == p.getPathCount()) { - // Is a child of the root, so it's a top-level folder - tree.getModel().removeNodeFromParent(// - (FileSystemTree.Node) p.getLastPathComponent()); - } - else { - JOptionPane.showMessageDialog(TextEditor.this, - "Can only remove top-level folders."); - } - }); - filter.addFocusListener(new FocusListener() { - @Override - public void focusLost(FocusEvent e) { - if (0 == filter.getText().length()) { - filter.setForeground(Color.gray); - filter.setText("filter..."); - } - } - - @Override - public void focusGained(FocusEvent e) { - if (filter.getForeground() == Color.gray) { - filter.setText(""); - filter.setForeground(Color.black); - } - } - }); - filter.addKeyListener(new KeyAdapter() { - Pattern pattern = null; - @Override - public void keyPressed(final KeyEvent ke) { - if (ke.getKeyCode() == KeyEvent.VK_ENTER) { - final String text = filter.getText(); - if (0 == text.length()) { - tree.setFileFilter(((f) -> true)); // any - return; - } - if ('/' == text.charAt(0)) { - // Interpret as a regular expression - // Attempt to compile the pattern - try { - String regex = text.substring(1); - if ('^' != regex.charAt(1)) regex = "^.*" + regex; - if ('$' != regex.charAt(regex.length() -1)) regex += ".*$"; - pattern = Pattern.compile(regex); - filter.setForeground(Color.black); - } catch (final PatternSyntaxException pse) { - log.warn(pse.getLocalizedMessage()); - filter.setForeground(Color.red); - pattern = null; - return; - } - if (null != pattern) { - tree.setFileFilter((f) -> pattern.matcher(f.getName()).matches()); - } - } else { - // Interpret as a literal match - tree.setFileFilter((f) -> -1 != f.getName().indexOf(text)); - } - } else { - // Upon re-typing something - if (filter.getForeground() == Color.red) { - filter.setForeground(Color.black); - } - } - } - }); - - // Restore top-level directories - tree.addTopLevelFoldersFrom(getEditorPane().loadFolders()); - - final GridBagLayout g = new GridBagLayout(); - tree_panel.setLayout(g); - final GridBagConstraints bc = new GridBagConstraints(); - bc.gridx = 0; - bc.gridy = 0; - bc.weightx = 0; - bc.weighty = 0; - bc.anchor = GridBagConstraints.NORTHWEST; - bc.fill = GridBagConstraints.NONE; - tree_panel.add(add_directory, bc); - bc.gridx = 1; - tree_panel.add(remove_directory, bc); - bc.gridx = 2; - bc.fill = GridBagConstraints.BOTH; - tree_panel.add(filter, bc); - bc.gridx = 0; - bc.gridwidth = 3; - bc.gridy = 1; - bc.weightx = 1.0; - bc.weighty = 1.0; - bc.fill = GridBagConstraints.BOTH; - tree_panel.add(tree, bc); - final JScrollPane scrolltree = new JScrollPane(tree_panel); - scrolltree.setBackground(Color.white); - scrolltree.setBorder(BorderFactory.createTitledBorder(BorderFactory.createEmptyBorder(0,5,0,5))); - scrolltree.setPreferredSize(new Dimension(200, 600)); - body = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, scrolltree, tabbed); - body.setOneTouchExpandable(true); - body.addPropertyChangeListener(evt -> { - if ("dividerLocation".equals(evt.getPropertyName())) saveWindowSizeToPrefs(); - }); getContentPane().add(body); // for Eclipse and MS Visual Studio lovers addAccelerator(compileAndRun, KeyEvent.VK_F11, 0, true); addAccelerator(compileAndRun, KeyEvent.VK_F5, 0, true); + compileAndRun.setToolTipText("Also triggered by F5 or F11"); addAccelerator(nextTab, KeyEvent.VK_PAGE_DOWN, ctrl, true); addAccelerator(previousTab, KeyEvent.VK_PAGE_UP, ctrl, true); - addAccelerator(increaseFontSize, KeyEvent.VK_EQUALS, ctrl | shift, true); // make sure that the window is not closed by accident @@ -813,6 +741,7 @@ public void windowClosing(final WindowEvent e) { } dragSource = null; getTab().destroy(); + cmdPalette.dispose(); dispose(); } }); @@ -825,19 +754,20 @@ public void windowGainedFocus(final WindowEvent e) { } }); - final Font font = new Font("Courier", Font.PLAIN, 12); - errorScreen.setFont(font); + // Tweaks for Console + errorScreen.setFont(getEditorPane().getFont()); errorScreen.setEditable(false); - errorScreen.setLineWrap(true); + errorScreen.setLineWrap(false); + applyConsolePopupMenu(errorScreen); setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); try { threadService.invoke(() -> { pack(); - body.setDividerLocation(0.2); - getTab().getScreenAndPromptSplit().setDividerLocation(1.0); - loadPreferences(); + body.setDividerLocation(0.2); // Important!: will be read as prefs. default + getTab().setREPLVisible(false); + loadWindowSizePreferences(); pack(); }); } @@ -867,21 +797,243 @@ public void componentResized(final ComponentEvent e) { open(null); final EditorPane editorPane = getEditorPane(); + // If dark L&F and using the default theme, assume 'dark' theme + applyTheme((GuiUtils.isDarkLaF() && "default".equals(editorPane.themeName())) ? "dark" : editorPane.themeName(), + true); + + // Ensure font sizes are consistent across all panels + setFontSize(getEditorPane().getFontSize()); + // Ensure menu commands are up-to-date + updateUI(true); + // Store locations of splitpanes + panePositions = new int[]{body.getDividerLocation(), getTab().getDividerLocation()}; editorPane.requestFocus(); } - + + private void resetLayout() { + body.setDividerLocation(.2d); + getTab().setOrientation(JSplitPane.VERTICAL_SPLIT); + getTab().setDividerLocation((incremental) ? .7d : .75d); + if (incremental) + getTab().getScreenAndPromptSplit().setDividerLocation(.5d); + getTab().setREPLVisible(incremental); + pack(); + } + + private void assembleEditMenu() { + // requires an existing instance of an EditorPane + final int ctrl = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask(); + final int shift = ActionEvent.SHIFT_MASK; + undo = addToMenu(editMenu, "Undo", KeyEvent.VK_Z, ctrl); + redo = addToMenu(editMenu, "Redo", KeyEvent.VK_Y, ctrl); + editMenu.addSeparator(); + selectAll = addToMenu(editMenu, "Select All", KeyEvent.VK_A, ctrl); + cut = addToMenu(editMenu, "Cut", KeyEvent.VK_X, ctrl); + copy = addToMenu(editMenu, "Copy", KeyEvent.VK_C, ctrl); + addMappedActionToMenu(editMenu, "Copy as Styled Text", EditorPaneActions.rstaCopyAsStyledTextAction, false); + paste = addToMenu(editMenu, "Paste", KeyEvent.VK_V, ctrl); + addMappedActionToMenu(editMenu, "Paste History...", EditorPaneActions.clipboardHistoryAction, true); + GuiUtils.addMenubarSeparator(editMenu, "Find:"); + find = addToMenu(editMenu, "Find/Replace...", KeyEvent.VK_F, ctrl); + find.setMnemonic(KeyEvent.VK_F); + findNext = addToMenu(editMenu, "Find Next", KeyEvent.VK_F3, 0); + findNext.setMnemonic(KeyEvent.VK_N); + findPrevious = addToMenu(editMenu, "Find Previous", KeyEvent.VK_F3, shift); + findPrevious.setMnemonic(KeyEvent.VK_P); + + GuiUtils.addMenubarSeparator(editMenu, "Go To:"); + gotoLine = addToMenu(editMenu, "Go to Line...", KeyEvent.VK_G, ctrl); + gotoLine.setMnemonic(KeyEvent.VK_G); + addMappedActionToMenu(editMenu, "Go to Matching Bracket", EditorPaneActions.rstaGoToMatchingBracketAction, false); + + final JMenuItem gotoType = new JMenuItem("Go to Type..."); + // we could retrieve the accelerator from paneactions but this may not work, if e.g., an + // unsupported syntax (such IJM) has been opened at startup, so we'll just specify it manually + //gotoType.setAccelerator(getEditorPane().getPaneActions().getAccelerator("GoToType")); + gotoType.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, ctrl + shift)); + gotoType.addActionListener(e -> { + try { + getTextArea().getActionMap().get("GoToType").actionPerformed(e); + } catch (final Exception | Error ignored) { + error("\"Goto Type\" not availabe for current scripting language."); + } + }); + editMenu.add(gotoType); + + GuiUtils.addMenubarSeparator(editMenu, "Bookmarks:"); + addMappedActionToMenu(editMenu, "Next Bookmark", EditorPaneActions.rtaNextBookmarkAction, false); + addMappedActionToMenu(editMenu, "Previous Bookmark", EditorPaneActions.rtaPrevBookmarkAction, false); + final JMenuItem toggB = addMappedActionToMenu(editMenu, "Toggle Bookmark", + EditorPaneActions.rtaToggleBookmarkAction, false); + toggB.setToolTipText("Alternatively, click on left bookmark gutter near the line number"); + final JMenuItem listBookmarks = addToMenu(editMenu, "List Bookmarks...", 0, 0); + listBookmarks.setMnemonic(KeyEvent.VK_L); + listBookmarks.addActionListener(e -> listBookmarks()); + final JMenuItem clearBookmarks = addToMenu(editMenu, "Clear Bookmarks...", 0, 0); + clearBookmarks.addActionListener(e -> clearAllBookmarks()); + + GuiUtils.addMenubarSeparator(editMenu, "Utilities:"); + final JMenuItem commentJMI = addMappedActionToMenu(editMenu, "Toggle Comment", + EditorPaneActions.rstaToggleCommentAction, true); + commentJMI.setToolTipText("Alternative shortcut: " + + getEditorPane().getPaneActions().getAcceleratorLabel(EditorPaneActions.epaToggleCommentAltAction)); + addMappedActionToMenu(editMenu, "Insert Time Stamp", EditorPaneActions.rtaTimeDateAction, true); + removeTrailingWhitespace = addToMenu(editMenu, "Remove Trailing Whitespace", 0, 0); + zapGremlins = addToMenu(editMenu, "Zap Gremlins", 0, 0); + zapGremlins.setToolTipText("Removes invalid (non-printable) ASCII characters"); + } + + private void addScritpEditorMacroCommands(final JMenu menu) { + GuiUtils.addMenubarSeparator(menu, "Script Editor Macros:"); + final JMenuItem startMacro = new JMenuItem("Start/Resume Macro Recording"); + startMacro.addActionListener(e -> { + if (getEditorPane().isLocked()) { + error("File is currently locked."); + return; + } + final String state = (RTextArea.getCurrentMacro() == null) ? "on" : "resumed"; + write("Script Editor: Macro recording " + state); + RTextArea.beginRecordingMacro(); + }); + menu.add(startMacro); + final JMenuItem pauseMacro = new JMenuItem("Pause Macro Recording..."); + pauseMacro.addActionListener(e -> { + if (!RTextArea.isRecordingMacro() || RTextArea.getCurrentMacro() == null) { + warn("No Script Editor Macro recording exists."); + } else { + RTextArea.endRecordingMacro(); + final int nSteps = RTextArea.getCurrentMacro().getMacroRecords().size(); + write("Script Editor: Macro recording off: " + nSteps + " event(s)/action(s) recorded."); + if (nSteps == 0) { + RTextArea.loadMacro(null); + } + } + }); + menu.add(pauseMacro); + final JMenuItem endMacro = new JMenuItem("Stop/Save Recording..."); + endMacro.addActionListener(e -> { + pauseMacro.doClick(); + if (RTextArea.getCurrentMacro() != null) { + final File fileToSave = getMacroFile(false); + if (fileToSave != null) { + try { + RTextArea.getCurrentMacro().saveToFile(fileToSave); + } catch (final IOException e1) { + error(e1.getMessage()); + e1.printStackTrace(); + } + } + } + }); + menu.add(endMacro); + final JMenuItem clearMacro = new JMenuItem("Clear Recorded Macro..."); + clearMacro.addActionListener(e -> { + if (RTextArea.getCurrentMacro() == null) { + warn("Nothing to clear: No macro has been recorded."); + return; + } + if (confirm("Clear Recorded Macro(s)?", "Clear Recording(s)?", "Clear")) { + RTextArea.loadMacro(null); + write("Script Editor: Recorded macro(s) cleared."); + } + }); + menu.add(clearMacro); + final JMenuItem playMacro = new JMenuItem("Run Recorded Macro"); + playMacro.setToolTipText("Runs current recordings. Prompts for\nrecordings file if no recordings exist"); + playMacro.addActionListener(e -> { + if (getEditorPane().isLocked()) { + error("File is currently locked."); + return; + } + if (null == RTextArea.getCurrentMacro()) { + final File fileToOpen = getMacroFile(true); + if (fileToOpen != null) { + try { + RTextArea.loadMacro(new Macro(fileToOpen)); + } catch (final IOException e1) { + error(e1.getMessage()); + e1.printStackTrace(); + } + } + } + if (RTextArea.isRecordingMacro()) { + if (confirm("Recording must be paused before execution. Pause recording now?", "Pause and Run?", + "Pause and Run")) { + RTextArea.endRecordingMacro(); + write("Script Editor: Recording paused"); + } else { + return; + } + } + if (RTextArea.getCurrentMacro() != null) { + final int actions = RTextArea.getCurrentMacro().getMacroRecords().size(); + write("Script Editor: Running recorded macro [" + actions + " event(s)/action(s)]"); + try { + getTextArea().playbackLastMacro(); + } catch (final Exception | Error ex) { + error("An Exception occured while running macro. See Console for details"); + ex.printStackTrace(); + } + } + }); + menu.add(playMacro); + } + + private File getMacroFile(final boolean openOtherwiseSave) { + final String msg = (openOtherwiseSave) ? "No macros have been recorded. Load macro from file?" + : "Recording Stopped. Save recorded macro to local file?"; + final String title = (openOtherwiseSave) ? "Load from File?" : "Save to File?"; + final String yesLabel = (openOtherwiseSave) ? "Load" : "Save"; + if (confirm(msg, title, yesLabel)) { + File dir = appService.getApp().getBaseDirectory(); + final String filename = "RecordedScriptEditorMacro.xml"; + if (getEditorPane().getFile() != null) { + dir = getEditorPane().getFile().getParentFile(); + } + return uiService.chooseFile(new File(dir, filename), + (openOtherwiseSave) ? FileWidget.OPEN_STYLE : FileWidget.SAVE_STYLE); + } + return null; + } + + private void displayRecordableMap() { + displayMap(cmdPalette.getRecordableActions(), "Script Editor Recordable Actions/Events"); + } + + private void displayKeyMap() { + displayMap(cmdPalette.getShortcuts(), "Script Editor Shortcuts"); + } + + private void displayMap(final Map map, final String windowTitle) { + final ArrayList lines = new ArrayList<>(); + map.forEach( (cmd, key) -> { + lines.add("" + cmd + + "" + key + ""); + }); + final String prefix = "

" // + + "" // + + "" // + + "" // + + "" // + + ""; // + final String suffix = "
ActionShortcut
"; + showHTMLDialog(windowTitle, prefix + String.join("", lines) + suffix); + } + private class DragAndDrop implements DragSourceListener, DragGestureListener { @Override - public void dragDropEnd(DragSourceDropEvent dsde) {} + public void dragDropEnd(final DragSourceDropEvent dsde) {} @Override - public void dragEnter(DragSourceDragEvent dsde) { + public void dragEnter(final DragSourceDragEvent dsde) { dsde.getDragSourceContext().setCursor(DragSource.DefaultMoveNoDrop); } @Override - public void dragGestureRecognized(DragGestureEvent dge) { - TreePath path = tree.getSelectionPath(); + public void dragGestureRecognized(final DragGestureEvent dge) { + final TreePath path = tree.getSelectionPath(); + if (path == null) // nothing is currently selected + return; final String filepath = (String)((FileSystemTree.Node) path.getLastPathComponent()).getUserObject(); dragSource.startDrag(dge, DragSource.DefaultCopyDrop, new Transferable() { @Override @@ -895,7 +1047,7 @@ public DataFlavor[] getTransferDataFlavors() { } @Override - public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException { + public Object getTransferData(final DataFlavor flavor) throws UnsupportedFlavorException, IOException { if (isDataFlavorSupported(flavor)) return Arrays.asList(new String[]{filepath}); return null; @@ -904,12 +1056,12 @@ public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorExcepti } @Override - public void dragExit(DragSourceEvent dse) { + public void dragExit(final DragSourceEvent dse) { dse.getDragSourceContext().setCursor(DragSource.DefaultMoveNoDrop); } @Override - public void dragOver(DragSourceDragEvent dsde) { + public void dragOver(final DragSourceDragEvent dsde) { if (tree == dsde.getSource()) { dsde.getDragSourceContext().setCursor(DragSource.DefaultCopyNoDrop); } else if (dsde.getDropAction() == DnDConstants.ACTION_COPY) { @@ -920,7 +1072,7 @@ public void dragOver(DragSourceDragEvent dsde) { } @Override - public void dropActionChanged(DragSourceDragEvent dsde) {} + public void dropActionChanged(final DragSourceDragEvent dsde) {} } public LogService log() { return log; } @@ -941,7 +1093,7 @@ private synchronized void initializeTokenMakers() { for (final PluginInfo info : pluginService .getPluginsOfType(SyntaxHighlighter.class)) try { - tokenMakerFactory.putMapping("text/" + info.getName(), info + tokenMakerFactory.putMapping("text/" + info.getName().toLowerCase().replace(' ', '-'), info .getClassName()); } catch (final Throwable t) { @@ -949,6 +1101,53 @@ private synchronized void initializeTokenMakers() { } } + private void initializeDynamicMenuComponents() { + + // Options menu. These will be updated once EditorPane is created + wrapLines = new JCheckBoxMenuItem("Wrap Lines", false); + wrapLines.setMnemonic(KeyEvent.VK_W); + marginLine = new JCheckBoxMenuItem("Show Margin Line", false); + marginLine.setToolTipText("Displays right margin at column 80"); + marginLine.addItemListener(e -> setMarginLineEnabled(marginLine.getState())); + wrapLines.addItemListener(e -> setWrapLines(wrapLines.getState())); + markOccurences = new JCheckBoxMenuItem("Mark Occurences", false); + markOccurences.setToolTipText("Allows for all occurrences of a double-clicked string to be" + + " highlighted.\nLines with hits are marked on the Editor's notification strip"); + markOccurences.addItemListener(e -> setMarkOccurrences(markOccurences.getState())); + whiteSpace = new JCheckBoxMenuItem("Show Whitespace", false); + whiteSpace.addItemListener(e -> setWhiteSpaceVisible(whiteSpace.isSelected())); + paintTabs = new JCheckBoxMenuItem("Show Indent Guides"); + paintTabs.setToolTipText("Displays 'tab lines' for leading whitespace"); + paintTabs.addItemListener(e -> setPaintTabLines(paintTabs.getState())); + autocompletion = new JCheckBoxMenuItem("Enable Autocompletion", true); + autocompletion.setToolTipText("Whether code completion should be used.\nNB: Not all languages support this feature"); + autocompletion.addItemListener(e -> setAutoCompletionEnabled(autocompletion.getState())); + keylessAutocompletion = new JCheckBoxMenuItem("Show Completions Without Ctrl+Space", false); + keylessAutocompletion.setToolTipText("If selected, the completion pop-up automatically appears while typing"); + keylessAutocompletion.addItemListener(e -> setKeylessAutoCompletion(keylessAutocompletion.getState())); + fallbackAutocompletion = new JCheckBoxMenuItem("Use Java Completions as Fallback", false); + fallbackAutocompletion.setToolTipText("If selected, Java completions will be used when scripting
" + + "a language for which auto-completions are not available"); + fallbackAutocompletion.addItemListener(e -> setFallbackAutoCompletion(fallbackAutocompletion.getState())); + themeRadioGroup = new ButtonGroup(); + + // Help menu. These are 'dynamic' items + openMacroFunctions = new JMenuItem("Open Help on Macro Function(s)..."); + openMacroFunctions.setMnemonic(KeyEvent.VK_H); + openMacroFunctions.addActionListener(e -> { + try { + new MacroFunctions(this).openHelp(getTextArea().getSelectedText()); + } catch (final IOException ex) { + handleException(ex); + } + }); + openHelp = new JMenuItem("Open Help for Class (With Frames)..."); + openHelp.setMnemonic(KeyEvent.VK_H); + openHelp.addActionListener( e-> openHelp(null)); + openHelpWithoutFrames = new JMenuItem("Open Help for Class..."); + openHelpWithoutFrames.addActionListener(e -> openHelp(null, false)); + } + /** * Check whether the file was edited outside of this {@link EditorPane} and * ask the user whether to reload. @@ -956,8 +1155,8 @@ private synchronized void initializeTokenMakers() { public void checkForOutsideChanges() { final EditorPane editorPane = getEditorPane(); if (editorPane.wasChangedOutside()) { - reload("The file " + editorPane.getFile().getName() + - " was changed outside of the editor"); + reload(editorPane.getFile().getName() + + "\nwas changed outside of the editor."); } } @@ -983,7 +1182,7 @@ private void onEvent( * Loads the Script Editor layout from persisted storage. * @see #saveWindowSizeToPrefs() */ - public void loadPreferences() { + public void loadWindowSizePreferences() { layoutLoading = true; final Dimension dim = getSize(); @@ -1007,9 +1206,10 @@ public void loadPreferences() { final TextEditorTab tab = getTab(); final int tabDivLocation = prefService.getInt(getClass(), TAB_DIV_LOCATION, tab.getDividerLocation()); final int tabDivOrientation = prefService.getInt(getClass(), TAB_DIV_ORIENTATION, tab.getOrientation()); + final int replDividerLocation = prefService.getInt(getClass(), REPL_DIV_LOCATION, tab.getScreenAndPromptSplit().getDividerLocation()); tab.setDividerLocation(tabDivLocation); tab.setOrientation(tabDivOrientation); - + tab.getScreenAndPromptSplit().setDividerLocation(replDividerLocation); layoutLoading = false; } @@ -1020,7 +1220,7 @@ public void loadPreferences() { * size when it's resized, however, we don't want to automatically save the * font, tab size, etc. without the user pressing "Save Preferences" *

- * @see #loadPreferences() + * @see #loadWindowSizePreferences() */ public void saveWindowSizeToPrefs() { if (layoutLoading) return; @@ -1034,6 +1234,7 @@ public void saveWindowSizeToPrefs() { final TextEditorTab tab = getTab(); prefService.put(getClass(), TAB_DIV_LOCATION, tab.getDividerLocation()); prefService.put(getClass(), TAB_DIV_ORIENTATION, tab.getOrientation()); + prefService.put(getClass(), REPL_DIV_LOCATION, tab.getScreenAndPromptSplit().getDividerLocation()); } final public RSyntaxTextArea getTextArea() { @@ -1099,6 +1300,30 @@ public JMenuItem addToMenu(final JMenu menu, final String menuEntry, return item; } + private JMenuItem addMappedActionToMenu(final JMenu menu, String label, String actionID, final boolean editingAction) { + final JMenuItem jmi = new JMenuItem(label); + jmi.addActionListener(e -> { + try { + if (editingAction && getEditorPane().isLocked()) { + warn("File is currently locked."); + return; + } + if (RTextAreaEditorKit.clipboardHistoryAction.equals(actionID) + && ClipboardHistory.get().getHistory().isEmpty()) { + warn("The internal clipboard manager is empty."); + return; + } + getTextArea().requestFocusInWindow(); + getTextArea().getActionMap().get(actionID).actionPerformed(e); + } catch (final Exception | Error ignored) { + error("\"" + label + "\" not availabe for current scripting language."); + } + }); + jmi.setAccelerator(getEditorPane().getPaneActions().getAccelerator(actionID)); + menu.add(jmi); + return jmi; + } + protected static class AcceleratorTriplet { JMenuItem component; @@ -1315,16 +1540,16 @@ public boolean handleUnsavedChanges(final boolean beforeCompiling) { save(); return true; } - - switch (JOptionPane.showConfirmDialog(this, "Do you want to save changes?")) { - case JOptionPane.NO_OPTION: - // Compiled languages should not progress if their source is unsaved - return !beforeCompiling; - case JOptionPane.YES_OPTION: - if (save()) return true; + if (GuiUtils.confirm(this, "Do you want to save changes?", "Save Changes?", "Save")) { + return save(); + } else { + // Compiled languages should not progress if their source is unsaved + return !beforeCompiling; } + } - return false; + private boolean isJava(final ScriptLanguage language) { + return language != null && language.getLanguageName().equals("Java"); } @Override @@ -1347,8 +1572,18 @@ else if (source == open) { else if (source == compileAndRun) runText(); else if (source == compile) compile(); else if (source == runSelection) runText(true); - else if (source == nextError) new Thread(() -> nextError(true)).start(); - else if (source == previousError) new Thread(() -> nextError(false)).start(); + else if (source == nextError) { + if (isJava(getEditorPane().getCurrentLanguage())) + new Thread(() -> nextError(true)).start(); + else + getEditorPane().getErrorHighlighter().gotoNextError(); + } + else if (source == previousError) { + if (isJava(getEditorPane().getCurrentLanguage())) + new Thread(() -> nextError(false)).start(); + else + getEditorPane().getErrorHighlighter().gotoPreviousError(); + } else if (source == kill) chooseTaskToKill(); else if (source == close) if (tabbed.getTabCount() < 2) processWindowEvent(new WindowEvent( this, WindowEvent.WINDOW_CLOSING)); @@ -1359,18 +1594,17 @@ else if (source == close) if (tabbed.getTabCount() < 2) processWindowEvent(new W if (index > 0) index--; switchTo(index); } - else if (source == cut) getTextArea().cut(); else if (source == copy) getTextArea().copy(); - else if (source == paste) getTextArea().paste(); - else if (source == undo) getTextArea().undoLastAction(); - else if (source == redo) getTextArea().redoLastAction(); - else if (source == find) findOrReplace(false); - else if (source == findNext) findDialog.searchOrReplace(false); - else if (source == findPrevious) findDialog.searchOrReplace(false, false); - else if (source == replace) findOrReplace(true); + else if (source == find) findOrReplace(true); + else if (source == findNext) { + findDialog.setRestrictToConsole(false); + findDialog.searchOrReplace(false); + } + else if (source == findPrevious) { + findDialog.setRestrictToConsole(false); + findDialog.searchOrReplace(false, false); + } else if (source == gotoLine) gotoLine(); - else if (source == toggleBookmark) toggleBookmark(); - else if (source == listBookmarks) listBookmarks(); else if (source == selectAll) { getTextArea().setCaretPosition(0); getTextArea().moveCaretPosition(getTextArea().getDocument().getLength()); @@ -1381,41 +1615,10 @@ else if (source == chooseFontSize) { else if (source == chooseTabSize) { commandService.run(ChooseTabSize.class, true, "editor", this); } - else if (source == addImport) { - addImport(getSelectedClassNameOrAsk()); - } - else if (source == removeUnusedImports) new TokenFunctions(getTextArea()) - .removeUnusedImports(); - else if (source == sortImports) new TokenFunctions(getTextArea()) - .sortImports(); - else if (source == removeTrailingWhitespace) new TokenFunctions( - getTextArea()).removeTrailingWhitespace(); - else if (source == replaceTabsWithSpaces) getTextArea() - .convertTabsToSpaces(); - else if (source == replaceSpacesWithTabs) getTextArea() - .convertSpacesToTabs(); - else if (source == clearScreen) { - getTab().getScreen().setText(""); - } - else if (source == zapGremlins) zapGremlins(); - else if (source == toggleAutoCompletionMenu) { - toggleAutoCompletion(); - } - else if (source == savePreferences) { - getEditorPane().savePreferences(tree.getTopLevelFoldersString()); - } - else if (source == openHelp) openHelp(null); - else if (source == openHelpWithoutFrames) openHelp(null, false); else if (source == openClassOrPackageHelp) openClassOrPackageHelp(null); - else if (source == openMacroFunctions) try { - new MacroFunctions(this).openHelp(getTextArea().getSelectedText()); - } - catch (final IOException e) { - handleException(e); - } else if (source == extractSourceJar) extractSourceJar(); else if (source == openSourceForClass) { - final String className = getSelectedClassNameOrAsk(); + final String className = getSelectedClassNameOrAsk("Class (fully qualified name):", "Which Class?"); if (className != null) { try { final String url = new FileFunctions(this).getSourceURL(className); @@ -1452,11 +1655,6 @@ else if (source == gitGrep) { commandService.run(GitGrep.class, true, "editor", this, "searchTerm", searchTerm, "searchRoot", searchRoot); } - else if (source == openInGitweb) { - final EditorPane editorPane = getEditorPane(); - new FileFunctions(this).openInGitweb(editorPane.getFile(), editorPane - .getGitDirectory(), editorPane.getCaretLineNumber() + 1); - } else if (source == increaseFontSize || source == decreaseFontSize) { getEditorPane().increaseFontSize( (float) (source == increaseFontSize ? 1.2 : 1 / 1.2)); @@ -1465,14 +1663,214 @@ else if (source == increaseFontSize || source == decreaseFontSize) { else if (source == nextTab) switchTabRelative(1); else if (source == previousTab) switchTabRelative(-1); else if (handleTabsMenu(source)) return; + else { + // commands that should not run when files are locked! + if (getEditorPane().isLocked()) { + error("File is currently locked."); + return; + } + if (source == cut) + getTextArea().cut(); + else if (source == paste) + getTextArea().paste(); + else if (source == undo) + getTextArea().undoLastAction(); + else if (source == redo) + getTextArea().redoLastAction(); + else if (source == addImport) { + addImport(getSelectedClassNameOrAsk("Add import (complete qualified name of class/package)", + "Which Class to Import?")); + } else if (source == removeUnusedImports) + new TokenFunctions(getTextArea()).removeUnusedImports(); + else if (source == sortImports) + new TokenFunctions(getTextArea()).sortImports(); + else if (source == removeTrailingWhitespace) + new TokenFunctions(getTextArea()).removeTrailingWhitespace(); + else if (source == replaceTabsWithSpaces) + getTextArea().convertTabsToSpaces(); + else if (source == replaceSpacesWithTabs) + getTextArea().convertSpacesToTabs(); + else if (source == zapGremlins) + zapGremlins(); + } + } + + private void setAutoCompletionEnabled(final boolean enabled) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setAutoCompletion(enabled); + keylessAutocompletion.setEnabled(enabled); + fallbackAutocompletion.setEnabled(enabled); } - private void toggleAutoCompletion() { - for (int i = 0; i < tabbed.getTabCount(); i++) { - final EditorPane editorPane = getEditorPane(i); - editorPane.setAutoCompletionEnabled(toggleAutoCompletionMenu.isSelected()); + private void setTabsEmulated(final boolean emulated) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setTabsEmulated(emulated); + getEditorPane().requestFocusInWindow(); + } + + private void setPaintTabLines(final boolean paint) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setPaintTabLines(paint); + getEditorPane().requestFocusInWindow(); + } + + private void setKeylessAutoCompletion(final boolean noKeyRequired) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setKeylessAutoCompletion(noKeyRequired); + getEditorPane().requestFocusInWindow(); + } + + private void setFallbackAutoCompletion(final boolean fallback) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setFallbackAutoCompletion(fallback); + getEditorPane().requestFocusInWindow(); + } + + private void setMarkOccurrences(final boolean markOccurrences) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setMarkOccurrences(markOccurrences); + getEditorPane().requestFocusInWindow(); + } + + private void setWhiteSpaceVisible(final boolean visible) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setWhitespaceVisible(visible); + getEditorPane().requestFocusInWindow(); + } + + private void setWrapLines(final boolean wrap) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setLineWrap(wrap); + getEditorPane().requestFocusInWindow(); + } + + private void setMarginLineEnabled(final boolean enabled) { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setMarginLineEnabled(enabled); + getEditorPane().requestFocusInWindow(); + } + + private JMenu applyThemeMenu() { + final LinkedHashMap map = new LinkedHashMap<>(); + map.put("Default", "default"); + map.put("-", "-"); + map.put("Dark", "dark"); + map.put("Druid", "druid"); + map.put("Monokai", "monokai"); + map.put("Eclipse (Light)", "eclipse"); + map.put("IntelliJ (Light)", "idea"); + map.put("Visual Studio (Light)", "vs"); + themeRadioGroup = new ButtonGroup(); + final JMenu menu = new JMenu("Theme"); + map.forEach((k, v) -> { + if ("-".equals(k)) { + menu.addSeparator(); + return; + } + final JRadioButtonMenuItem item = new JRadioButtonMenuItem(k); + item.setActionCommand(v); // needed for #updateThemeControls() + themeRadioGroup.add(item); + item.addActionListener(e -> { + try { + applyTheme(v, false); + // make the choice available for the next tab + prefService.put(EditorPane.class, EditorPane.THEME_PREFS, v); + } catch (final IllegalArgumentException ex) { + error("Theme could not be loaded. See Console for details."); + ex.printStackTrace(); + } + }); + menu.add(item); + }); + return menu; + } + + /** + * Applies a theme to all the panes of this editor. + * + * @param theme either "default", "dark", "druid", "eclipse", "idea", "monokai", + * "vs" + * @throws IllegalArgumentException If {@code theme} is not a valid option, or + * the resource could not be loaded + */ + public void applyTheme(final String theme) throws IllegalArgumentException { + applyTheme(theme, true); + } + + private void applyTheme(final String theme, final boolean updateMenus) throws IllegalArgumentException { + try { + final Theme th = getTheme(theme); + if (th == null) { + writeError("Unrecognized theme ignored: '" + theme + "'"); + return; + } + for (int i = 0; i < tabbed.getTabCount(); i++) { + getEditorPane(i).applyTheme(theme); + } + } catch (final Exception ex) { + activeTheme = "default"; + updateThemeControls("default"); + writeError("Could not load theme. See Console for details."); + updateThemeControls(activeTheme); + throw new IllegalArgumentException(ex); } - prefService.put(TextEditor.class, "autoComplete", toggleAutoCompletionMenu.isSelected()); + activeTheme = theme; + if (updateMenus) updateThemeControls(theme); + } + + static Theme getTheme(final String theme) throws IllegalArgumentException { + try { + return Theme + .load(TextEditor.class.getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/" + theme + ".xml")); + } catch (final Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private void updateThemeControls(final String theme) { + if (themeRadioGroup != null) { + final Enumeration choices = themeRadioGroup.getElements(); + while (choices.hasMoreElements()) { + final AbstractButton choice = choices.nextElement(); + if (theme.equals(choice.getActionCommand())) { + choice.setSelected(true); + break; + } + } + } + } + + private void collapseSplitPane(final int pane, final boolean collapse) { + final JSplitPane jsp = (pane == 0) ? body : getTab(); + if (collapse) { + panePositions[pane] = jsp.getDividerLocation(); + if (pane == 0) { // collapse to left + jsp.setDividerLocation(0.0d); + } else { // collapse to bottom + jsp.setDividerLocation(1.0d); + } + } else { + jsp.setDividerLocation(panePositions[pane]); + // Check if user collapsed pane manually (stashed panePosition is invalid) + final boolean expanded = (pane == 0) ? isLeftPaneExpanded(jsp) : isRightOrBottomPaneExpanded(jsp); + if (!expanded + // && JOptionPane.OK_OPTION == JOptionPane.showConfirmDialog(TextEditor.this, // + // "Expand to default position?", "Expand to Defaults?", JOptionPane.OK_CANCEL_OPTION) + ) { + jsp.setDividerLocation((pane == 0) ? .2d : .75d); + panePositions[pane] = jsp.getDividerLocation(); + } + } + } + + private boolean isLeftPaneExpanded(final JSplitPane pane) { + return pane.isVisible() && pane.getLeftComponent().getWidth() > 0; + } + + private boolean isRightOrBottomPaneExpanded(final JSplitPane pane) { + final int dim = (pane.getOrientation() == JSplitPane.VERTICAL_SPLIT) ? pane.getRightComponent().getHeight() + : pane.getRightComponent().getWidth(); + return pane.isVisible() && dim > 0; } protected boolean handleTabsMenu(final Object source) { @@ -1495,10 +1893,11 @@ public void stateChanged(final ChangeEvent e) { return; } final EditorPane editorPane = getEditorPane(index); + lockPane.setSelected(editorPane.isLocked()); editorPane.requestFocus(); checkForOutsideChanges(); - toggleWhiteSpaceLabeling.setSelected(editorPane.isWhitespaceVisible()); + //whiteSpace.setSelected(editorPane.isWhitespaceVisible()); editorPane.setLanguageByFileName(editorPane.getFileName()); updateLanguageMenu(editorPane.getCurrentLanguage()); @@ -1512,19 +1911,18 @@ public EditorPane getEditorPane(final int index) { public void findOrReplace(final boolean doReplace) { findDialog.setLocationRelativeTo(this); + findDialog.setRestrictToConsole(false); // override search pattern only if // there is sth. selected - final String selection = getTextArea().getSelectedText(); + final String selection = getEditorPane().getSelectedText(); if (selection != null) findDialog.setSearchPattern(selection); - findDialog.show(doReplace); + findDialog.show(doReplace && !getEditorPane().isLocked()); } public void gotoLine() { - final String line = - JOptionPane.showInputDialog(this, "Line:", "Goto line...", - JOptionPane.QUESTION_MESSAGE); + final String line = GuiUtils.getString(this, "Enter line number:", "Goto Line"); if (line == null) return; try { gotoLine(Integer.parseInt(line)); @@ -1545,16 +1943,34 @@ public void toggleBookmark() { getEditorPane().toggleBookmark(); } - public void listBookmarks() { + private Vector getAllBookmarks() { final Vector bookmarks = new Vector<>(); - for (int i = 0; i < tabbed.getTabCount(); i++) { final TextEditorTab tab = (TextEditorTab) tabbed.getComponentAt(i); tab.editorPane.getBookmarks(tab, bookmarks); } + if (bookmarks.isEmpty()) { + info("No Bookmarks currently exist.\nYou can bookmark lines by clicking next to their line number.", + "No Bookmarks"); + } + return bookmarks; + } - final BookmarkDialog dialog = new BookmarkDialog(this, bookmarks); - dialog.setVisible(true); + public void listBookmarks() { + final Vector bookmarks = getAllBookmarks(); + if (!bookmarks.isEmpty()) { + new BookmarkDialog(this, bookmarks).setVisible(true); + } + } + + void clearAllBookmarks() { + final Vector bookmarks = getAllBookmarks(); + if (bookmarks.isEmpty()) + return; + ; + if (confirm("Delete all bookmarks?", "Confirm Deletion?", "Delete")) { + bookmarks.forEach(bk -> bk.tab.editorPane.toggleBookmark(bk.getLineNumber())); + } } public boolean reload() { @@ -1562,15 +1978,19 @@ public boolean reload() { } public boolean reload(final String message) { + return reloadRevert(message, "Reload"); + } + + private boolean reloadRevert(final String message, final String title) { final EditorPane editorPane = getEditorPane(); final File file = editorPane.getFile(); if (file == null || !file.exists()) return true; final boolean modified = editorPane.fileChanged(); - final String[] options = { "Reload", "Do not reload" }; - if (modified) options[0] = "Reload (discarding changes)"; - switch (JOptionPane.showOptionDialog(this, message, "Reload", + final String[] options = { title, "Do Not " + title }; + if (modified) options[0] = title + " (Discard Changes)"; + switch (JOptionPane.showOptionDialog(this, message, title + "?", JOptionPane.DEFAULT_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0])) { case 0: @@ -1613,20 +2033,24 @@ public static boolean isBinary(final File file) { } /** - * Open a new tab with some content; the languageExtension is like ".java", - * ".py", etc. + * Opens a new tab with some content. + * + * @param content the script content + * @param languageExtension the file extension associated with the content's + * language (e.g., ".java", ".py", etc. + * @return the text editor tab */ - public TextEditorTab newTab(final String content, final String language) { - String lang = language; + public TextEditorTab newTab(final String content, final String languageExtension) { + String lang = languageExtension; final TextEditorTab tab = open(null); if (null != lang && lang.length() > 0) { lang = lang.trim().toLowerCase(); if ('.' != lang.charAt(0)) { - lang = "." + language; + lang = "." + languageExtension; } - tab.editorPane.setLanguage(scriptService.getLanguageByName(language)); + tab.editorPane.setLanguage(scriptService.getLanguageByName(languageExtension)); } else { final String lastLanguageName = prefService.get(getClass(), LAST_LANGUAGE); if (null != lastLanguageName && "none" != lastLanguageName) @@ -1644,7 +2068,7 @@ public TextEditorTab open(final File file) { if (isBinary(file)) { try { uiService.show(ioService.open(file.getAbsolutePath())); - } catch (IOException e) { + } catch (final IOException e) { log.error(e); } return null; @@ -1652,7 +2076,7 @@ public TextEditorTab open(final File file) { try { TextEditorTab tab = (tabbed.getTabCount() == 0) ? null : getTab(); - TextEditorTab prior = tab; + final TextEditorTab prior = tab; final boolean wasNew = tab != null && tab.editorPane.isNew(); float font_size = 0; // to set the new editor's font like the last active one, if any if (!wasNew) { @@ -1660,8 +2084,12 @@ public TextEditorTab open(final File file) { tab = new TextEditorTab(this); context.inject(tab.editorPane); tab.editorPane.loadPreferences(); - tab.editorPane.getDocument().addDocumentListener(this); addDefaultAccelerators(tab.editorPane); + } else { + // the Edit menu can only be populated after an editor pane exists, as it reads + // actions from its input map. We will built it here, if it has not been assembled + // yet. + if (undo == null) assembleEditMenu(); } synchronized (tab.editorPane) { // tab is never null at this location. tab.editorPane.open(file); @@ -1696,16 +2124,17 @@ public TextEditorTab open(final File file) { } updateLanguageMenu(tab.editorPane.getCurrentLanguage()); - + tab.editorPane.getDocument().addDocumentListener(this); + tab.editorPane.requestFocusInWindow(); return tab; } catch (final FileNotFoundException e) { log.error(e); - error("The file '" + file + "' was not found."); + error("The file\n'" + file + "' was not found."); } catch (final Exception e) { log.error(e); - error("There was an error while opening '" + file + "': " + e); + error("There was an error while opening\n'" + file + "': " + e); } return null; } @@ -1728,11 +2157,11 @@ public void saveAs(final String path) { public boolean saveAs(final String path, final boolean askBeforeReplacing) { final File file = new File(path); - if (file.exists() && - askBeforeReplacing && - JOptionPane.showConfirmDialog(this, "Do you want to replace " + path + - "?", "Replace " + path + "?", JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) return false; - if (!write(file)) return false; + if (file.exists() && askBeforeReplacing + && confirm("Do you want to replace " + path + "?", "Replace " + path + "?", "Replace")) + return false; + if (!write(file)) + return false; setEditorPaneFileName(file); openRecent.add(path); return true; @@ -1778,10 +2207,9 @@ public boolean makeJar(final boolean includeSources) { final File selectedFile = uiService.chooseFile(file, FileWidget.SAVE_STYLE); if (selectedFile == null) return false; - if (selectedFile.exists() && - JOptionPane.showConfirmDialog(this, "Do you want to replace " + - selectedFile + "?", "Replace " + selectedFile + "?", - JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) return false; + if (selectedFile.exists() + && confirm("Do you want to replace " + selectedFile + "?", "Replace " + selectedFile + "?", "Replace")) + return false; try { makeJar(selectedFile, includeSources); return true; @@ -1876,62 +2304,81 @@ void setLanguage(final ScriptLanguage language, final boolean addHeader) { this.scriptInfo = null; } getEditorPane().setLanguage(language, addHeader); - prefService.put(getClass(), LAST_LANGUAGE, null == language? "none" : language.getLanguageName()); setTitle(); updateLanguageMenu(language); - updateTabAndFontSize(true); + updateUI(true); } + private String lastSupportStatus = null; + void updateLanguageMenu(final ScriptLanguage language) { JMenuItem item = languageMenuItems.get(language); - if (item == null) item = noneLanguageItem; + if (item == null) { + // is none + item = noneLanguageItem; + setIncremental(false); + } if (!item.isSelected()) { item.setSelected(true); } + // print autocompletion status to console + String supportStatus = getEditorPane().getSupportStatus(); + if (supportStatus != null && !Objects.equals(supportStatus, lastSupportStatus)) { + write(supportStatus); + lastSupportStatus = supportStatus; + } final boolean isRunnable = item != noneLanguageItem; final boolean isCompileable = language != null && language.isCompiledLanguage(); - runMenu.setVisible(isRunnable); + runMenu.setEnabled(isRunnable); compileAndRun.setText(isCompileable ? "Compile and Run" : "Run"); compileAndRun.setEnabled(isRunnable); - runSelection.setVisible(isRunnable && !isCompileable); - compile.setVisible(isCompileable); - autoSave.setVisible(isCompileable); - makeJar.setVisible(isCompileable); - makeJarWithSource.setVisible(isCompileable); - - final boolean isJava = - language != null && language.getLanguageName().equals("Java"); - addImport.setVisible(isJava); - removeUnusedImports.setVisible(isJava); - sortImports.setVisible(isJava); - openSourceForMenuItem.setVisible(isJava); - - final boolean isMacro = - language != null && language.getLanguageName().equals("ImageJ Macro"); - openMacroFunctions.setVisible(isMacro); - openSourceForClass.setVisible(!isMacro); - - openHelp.setVisible(!isMacro && isRunnable); - openHelpWithoutFrames.setVisible(!isMacro && isRunnable); - nextError.setVisible(!isMacro && isRunnable); - previousError.setVisible(!isMacro && isRunnable); + runSelection.setEnabled(isRunnable && !isCompileable); + compile.setEnabled(isCompileable); + autoSave.setEnabled(isCompileable); + makeJar.setEnabled(isCompileable); + makeJarWithSource.setEnabled(isCompileable); + + final boolean isJava = + language != null && language.getLanguageName().equals("Java"); + addImport.setEnabled(isJava); + removeUnusedImports.setEnabled(isJava); + sortImports.setEnabled(isJava); + //openSourceForMenuItem.setEnabled(isJava); + + final boolean isMacro = + language != null && language.getLanguageName().equals("ImageJ Macro"); + openMacroFunctions.setEnabled(isMacro); + openSourceForClass.setEnabled(!isMacro); + + openHelp.setEnabled(!isMacro && isRunnable); + openHelpWithoutFrames.setEnabled(!isMacro && isRunnable); + nextError.setEnabled(!isMacro && isRunnable); + previousError.setEnabled(!isMacro && isRunnable); final boolean isInGit = getEditorPane().getGitDirectory() != null; gitMenu.setVisible(isInGit); - updateTabAndFontSize(false); + updateUI(false); } + /** + * Use {@link #updateUI(boolean)} instead + */ + @Deprecated public void updateTabAndFontSize(final boolean setByLanguage) { + updateUI(setByLanguage); + } + + public void updateUI(final boolean setByLanguage) { final EditorPane pane = getEditorPane(); - if (pane.getCurrentLanguage() == null) return; + //if (pane.getCurrentLanguage() == null) return; - if (setByLanguage) { + if (setByLanguage && pane.getCurrentLanguage() != null) { if (pane.getCurrentLanguage().getLanguageName().equals("Python")) { pane.setTabSize(4); } @@ -1955,6 +2402,7 @@ else if (tabSize == Integer.parseInt(item.getText())) { defaultSize = true; } } + getTab().prompt.setTabSize(getEditorPane().getTabSize()); final int fontSize = (int) pane.getFontSize(); defaultSize = false; for (int i = 0; i < fontSizeMenu.getItemCount(); i++) { @@ -1972,8 +2420,16 @@ else if (tabSize == Integer.parseInt(item.getText())) { defaultSize = true; } } + markOccurences.setState(pane.getMarkOccurrences()); wrapLines.setState(pane.getLineWrap()); + marginLine.setState(pane.isMarginLineEnabled()); tabsEmulated.setState(pane.getTabsEmulated()); + paintTabs.setState(pane.getPaintTabLines()); + whiteSpace.setState(pane.isWhitespaceVisible()); + autocompletion.setState(pane.isAutoCompletionEnabled()); + fallbackAutocompletion.setState(pane.isAutoCompletionFallbackEnabled()); + keylessAutocompletion.setState(pane.isAutoCompletionKeyless()); + sourceTreePanel.rebuildSourceTree(pane); } public void setEditorPaneFileName(final String baseName) { @@ -2219,8 +2675,17 @@ public void runText(final boolean selectionOnly) { * Run current script with the batch processor */ public void runBatch() { + if (null == getCurrentLanguage()) { + error("Select a language first! Also, please note that this option\n" + + "requires at least one @File parameter to be declared in the script."); + return; + } // get script from current tab final String script = getTab().getEditorPane().getText(); + if (script.trim().isEmpty()) { + error("This option requires at least one @File parameter to be declared."); + return; + } final ScriptInfo info = new ScriptInfo(context, // "dummy." + getCurrentLanguage().getExtensions().get(0), // new StringReader(script)); @@ -2238,6 +2703,7 @@ private void execute(final boolean selectionOnly) throws IOException { text = null; } else text = selected + "\n"; // Ensure code blocks are terminated + getEditorPane().getErrorHighlighter().setSelectedCodeExecution(true); } else { text = tab.getEditorPane().getText(); @@ -2342,7 +2808,7 @@ private void writePromptLog(final ScriptLanguage language, final String text) { final String path = getPromptCommandsFilename(language); final File file = new File(path); try { - boolean exists = file.exists(); + final boolean exists = file.exists(); if (!exists) { // Ensure parent directories exist file.getParentFile().mkdirs(); @@ -2350,7 +2816,7 @@ private void writePromptLog(final ScriptLanguage language, final String text) { } Files.write(Paths.get(path), Arrays.asList(new String[]{text, "#"}), Charset.forName("UTF-8"), StandardOpenOption.APPEND, StandardOpenOption.DSYNC); - } catch (IOException e) { + } catch (final IOException e) { log.error("Failed to write executed prompt command to file " + path, e); } } @@ -2376,11 +2842,11 @@ private ArrayList loadPromptLog(final ScriptLanguage language) { final String sep = System.getProperty("line.separator"); // used fy Files.write above commands.addAll(Arrays.asList(new String(bytes, Charset.forName("UTF-8")).split(sep + "#" + sep))); if (0 == commands.get(commands.size()-1).length()) commands.remove(commands.size() -1); // last entry is empty - } catch (IOException e) { + } catch (final IOException e) { log.error("Failed to read history of prompt commands from file " + path, e); return lines; } finally { - try { if (null != ra) ra.close(); } catch (IOException e) { log.error(e); } + try { if (null != ra) ra.close(); } catch (final IOException e) { log.error(e); } } if (commands.size() > 1000) { commands = commands.subList(commands.size() - 1000, commands.size()); @@ -2396,7 +2862,7 @@ private ArrayList loadPromptLog(final ScriptLanguage language) { if (!new File(path + "-tmp").renameTo(new File(path))) { log.error("Could not rename command log file " + path + "-tmp to " + path); } - } catch (Exception e) { + } catch (final Exception e) { log.error("Failed to crop history of prompt commands file " + path, e); } } @@ -2418,9 +2884,7 @@ public void runScript() { @Override public void execute() { - try (final Reader reader = evalScript(getEditorPane().getFile() - .getPath(), new FileReader(file), output, errors)) - { + try (final Reader reader = evalScript(file.getPath(), new FileReader(file), output, errors)) { output.flush(); errors.flush(); markCompileEnd(); @@ -2450,19 +2914,16 @@ public void compile() { } } - public String getSelectedTextOrAsk(final String label) { + private String getSelectedTextOrAsk(final String msg, final String title) { String selection = getTextArea().getSelectedText(); if (selection == null || selection.indexOf('\n') >= 0) { - selection = - JOptionPane.showInputDialog(this, label + ":", label + "...", - JOptionPane.QUESTION_MESSAGE); - if (selection == null) return null; + return GuiUtils.getString(this, msg + "\nAlternatively, select a class declaration and re-run.", title); } return selection; } - public String getSelectedClassNameOrAsk() { - String className = getSelectedTextOrAsk("Class name"); + private String getSelectedClassNameOrAsk(final String msg, final String title) { + String className = getSelectedTextOrAsk(msg, title); if (className != null) className = className.trim(); return className; } @@ -2472,7 +2933,7 @@ private static void append(final JTextArea textArea, final String text) { textArea.insert(text, length); textArea.setCaretPosition(length); } - + public void markCompileStart() { markCompileStart(true); } @@ -2497,15 +2958,17 @@ public void markCompileStart(final boolean with_timestamp) { ExceptionHandler.addThread(Thread.currentThread(), this); } - public void markCompileEnd() { + public void markCompileEnd() { // this naming is not-intuitive at all! if (errorHandler == null) { errorHandler = new ErrorHandler(getCurrentLanguage(), errorScreen, compileStartPosition.getOffset()); if (errorHandler.getErrorCount() > 0) getTab().showErrors(); } - if (compileStartOffset != errorScreen.getDocument().getLength()) getTab() - .showErrors(); + if (getEditorPane().getErrorHighlighter().isLogDetailed() && + compileStartOffset != errorScreen.getDocument().getLength()) { + getTab().showErrors(); + } if (getTab().showingErrors) { errorHandler.scrollToVisible(compileStartOffset); } @@ -2571,10 +3034,14 @@ private void switchTabRelative(final int delta) { private void removeTab(final int index) { final int menuItemIndex = index + tabsMenuTabsStart; - - tabbed.remove(index); - tabsMenuItems.remove(tabsMenu.getItem(menuItemIndex)); - tabsMenu.remove(menuItemIndex); + try { + tabbed.remove(index); + tabsMenuItems.remove(tabsMenu.getItem(menuItemIndex)); + tabsMenu.remove(menuItemIndex); + } catch (final IndexOutOfBoundsException e) { + // this should never happen!? + log.debug(e); + } } boolean editorPaneContainsFile(final EditorPane editorPane, final File file) { @@ -2624,7 +3091,7 @@ public void openHelp(final String className) { * @param withFrames */ public void openHelp(String className, final boolean withFrames) { - if (className == null) className = getSelectedClassNameOrAsk(); + if (className == null) className = getSelectedClassNameOrAsk("Class (fully qualified name):", "Online Javadocs..."); if (className == null) return; final Class c = Types.load(className, false); @@ -2682,17 +3149,18 @@ public void openHelp(String className, final boolean withFrames) { handleException(e); } } - + /** - * @param text Either a classname, or a partial class name, or package name or any part of the fully qualified class name. + * @param text Either a classname, or a partial class name, or package name or + * any part of the fully qualified class name. */ public void openClassOrPackageHelp(String text) { if (text == null) - text = getSelectedClassNameOrAsk(); + text = getSelectedClassNameOrAsk("Class or package (complete or partial name, e.g., 'ij'):", "Lookup Which Class/Package?"); if (null == text) return; new Thread(new FindClassSourceAndJavadoc(text)).start(); // fork away from event dispatch thread } - + public class FindClassSourceAndJavadoc implements Runnable { private final String text; public FindClassSourceAndJavadoc(final String text) { @@ -2708,14 +3176,17 @@ public void run() { setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); } if (matches.isEmpty()) { - JOptionPane.showMessageDialog(getEditorPane(), "No info found for:\n'" + text +'"'); + if (confirm("No info found for: '" + text + "'.\nSearch for it on the web?", "Search the Web?", + "Search")) { + GuiUtils.runSearchQueryInBrowser(TextEditor.this, getPlatformService(), text.trim()); + } return; } final JPanel panel = new JPanel(); final GridBagLayout gridbag = new GridBagLayout(); final GridBagConstraints c = new GridBagConstraints(); panel.setLayout(gridbag); - panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + //panel.setBorder(BorderFactory.createEmptyBorder(BORDER_SIZE, BORDER_SIZE, BORDER_SIZE, BORDER_SIZE)); final List keys = new ArrayList(matches.keySet()); Collections.sort(keys); c.gridy = 0; @@ -2739,27 +3210,20 @@ public void run() { final JButton link = new JButton(title); gridbag.setConstraints(link, c); panel.add(link); - link.addActionListener(new ActionListener() { - public void actionPerformed(final ActionEvent event) { - try { - platformService.open(new URL(url)); - } catch (Exception e) { - e.printStackTrace(); - } - } + link.addActionListener(event -> { + GuiUtils.openURL(TextEditor.this, platformService, url); }); } c.gridy += 1; } final JScrollPane jsp = new JScrollPane(panel); //jsp.setPreferredSize(new Dimension(800, 500)); - SwingUtilities.invokeLater(new Runnable() { - public void run() { - final JFrame frame = new JFrame(text); - frame.getContentPane().add(jsp); - frame.pack(); - frame.setVisible(true); - } + SwingUtilities.invokeLater(() -> { + final JFrame frame = new JFrame("Resources for '" + text +"'"); + frame.getContentPane().add(jsp); + frame.setLocationRelativeTo(TextEditor.this); + frame.pack(); + frame.setVisible(true); }); } } @@ -2813,12 +3277,29 @@ public void writeError(String message) { errorScreen.insert(message, errorScreen.getDocument().getLength()); } - private void error(final String message) { - JOptionPane.showMessageDialog(this, message); + void error(final String message) { + GuiUtils.error(this, message); + } + + void warn(final String message) { + GuiUtils.warn(this, message); + } + + void info(final String message, final String title) { + GuiUtils.info(this, message, title); + } + + boolean confirm(final String message, final String title, final String yesButtonLabel) { + return GuiUtils.confirm(this, message, title, yesButtonLabel); + } + + void showHTMLDialog(final String title, final String htmlContents) { + GuiUtils.showHTMLDialog(this, title, htmlContents); } public void handleException(final Throwable e) { handleException(e, errorScreen); + getEditorPane().getErrorHighlighter().parse(e); getTab().showErrors(); } @@ -2848,7 +3329,7 @@ public int zapGremlins() { final String msg = count > 0 ? "Zap Gremlins converted " + count + " invalid characters to spaces" : "No invalid characters found!"; - JOptionPane.showMessageDialog(this, msg); + info(msg, "Zap Gremlins"); return count; } @@ -2887,7 +3368,7 @@ public String getProcessedScript() { } private Reader evalScript(final String filename, Reader reader, - final Writer output, final Writer errors) throws ModuleException + final Writer output, final JTextAreaWriter errors) throws ModuleException { final ScriptLanguage language = getCurrentLanguage(); @@ -2918,7 +3399,7 @@ private Reader evalScript(final String filename, Reader reader, try { // Same engine, with persistent state this.scriptInfo.setScript( reader ); - } catch (IOException e) { + } catch (final IOException e) { log.error(e); } } @@ -2927,6 +3408,11 @@ private Reader evalScript(final String filename, Reader reader, this.module.setOutputWriter(output); this.module.setErrorWriter(errors); + // prepare the highlighter + getEditorPane().getErrorHighlighter().setEnabled(!respectAutoImports); + getEditorPane().getErrorHighlighter().reset(); + getEditorPane().getErrorHighlighter().setWriter(errors); + // execute the script try { moduleService.run(module, true).get(); @@ -2936,22 +3422,24 @@ private Reader evalScript(final String filename, Reader reader, } catch (final ExecutionException e) { log.error(e); + } finally { + getEditorPane().getErrorHighlighter().parse(); } return reader; } public void setIncremental(final boolean incremental) { - if (null == getCurrentLanguage()) { + if (incremental && null == getCurrentLanguage()) { error("Select a language first!"); return; } this.incremental = incremental; - final JTextArea prompt = this.getTab().getPrompt(); + final JTextArea prompt = getTab().getPrompt(); if (incremental) { - getTab().getScreenAndPromptSplit().setDividerLocation(0.5); + getTab().setREPLVisible(true); prompt.addKeyListener(new KeyAdapter() { private final ArrayList commands = loadPromptLog(getCurrentLanguage()); private int index = commands.size(); @@ -2986,8 +3474,9 @@ public void keyPressed(final KeyEvent ke) { execute(getTab(), text, true); prompt.setText(""); screen.scrollRectToVisible(screen.modelToView(screen.getDocument().getLength())); - } catch (Throwable t) { + } catch (final Throwable t) { log.error(t); + prompt.requestFocusInWindow(); } ke.consume(); // avoid writing the line break return; @@ -3051,7 +3540,7 @@ public void keyPressed(final KeyEvent ke) { for (final KeyListener kl : prompt.getKeyListeners()) { prompt.removeKeyListener(kl); } - getTab().getScreenAndPromptSplit().setDividerLocation(1.0); + getTab().setREPLVisible(false); } } @@ -3128,4 +3617,396 @@ public void setFontSize(final float size) { private void changeFontSize(final JTextArea a, final float size) { a.setFont(a.getFont().deriveFont(size)); } + + private void appendPreferences(final JMenu menu) { + JMenuItem item = new JMenuItem("Save Preferences"); + menu.add(item); + item.addActionListener(e -> { + getEditorPane().savePreferences(tree.getTopLevelFoldersString(), activeTheme); + saveWindowSizeToPrefs(); + write("Script Editor: Preferences Saved...\n"); + }); + item = new JMenuItem("Reset..."); + menu.add(item); + item.addActionListener(e -> { + if (confirm("Reset preferences to defaults? (a restart may be required)", "Reset?", "Reset")) { + resetLayout(); + prefService.clear(EditorPane.class); + prefService.clear(TextEditor.class); + write("Script Editor: Preferences Reset. Restart is recommended\n"); + } + }); + } + + private JMenu helpMenu() { + final JMenu menu = new JMenu("Help"); + GuiUtils.addMenubarSeparator(menu, "Offline Help:"); + JMenuItem item = new JMenuItem("List Shortcuts..."); + item.addActionListener(e -> displayKeyMap()); + menu.add(item); + item = new JMenuItem("List Recordable Actions..."); + item.addActionListener(e -> displayRecordableMap()); + menu.add(item); + item = new JMenuItem("Task Tags How-To..."); + item.addActionListener(e -> { + showHTMLDialog("Task Tags Help", // + "

" + + "When inserted in source code comments, the following keywords will automatically " // + + "register task definitions on the rightmost side of the Editor: TODO, " // + + "README, and HACK.

" // + + "
    " // + + "
  • To add a task, simply type one of the keywords in a commented line, e.g., "// + + "TODO
  • "// + + "
  • To remove a task, delete the keyword from the comment
  • " // + + "
  • Mouse over the annotation mark to access a summary of the task
  • " // + + "
  • Click on the mark to go to the annotated line
  • " + + "
"); + }); + menu.add(item); + GuiUtils.addMenubarSeparator(menu, "Contextual Help:"); + menu.add(openHelpWithoutFrames); + openHelpWithoutFrames.setMnemonic(KeyEvent.VK_O); + menu.add(openHelp); + openClassOrPackageHelp = addToMenu(menu, "Lookup Class or Package...", 0, 0); + openClassOrPackageHelp.setMnemonic(KeyEvent.VK_S); + menu.add(openMacroFunctions); + GuiUtils.addMenubarSeparator(menu, "Online Resources:"); + menu.add(helpMenuItem("Image.sc Forum ", "https://forum.image.sc/")); + menu.add(helpMenuItem("ImageJ Search Portal", "https://search.imagej.net/")); + //menu.addSeparator(); + menu.add(helpMenuItem("SciJava Javadoc Portal", "https://javadoc.scijava.org/")); + menu.add(helpMenuItem("SciJava Maven Repository", "https://maven.scijava.org/")); + menu.addSeparator(); + menu.add(helpMenuItem("Fiji on GitHub", "https://github.com/fiji")); + menu.add(helpMenuItem("SciJava on GitHub", "https://github.com/scijava/")); + menu.addSeparator(); + menu.add(helpMenuItem("ImageJ Macro Functions", "https://imagej.nih.gov/ij/developer/macro/functions.html")); + menu.add(helpMenuItem("ImageJ Docs: Development", "https://imagej.net/develop/")); + menu.add(helpMenuItem("ImageJ Docs: Scripting", "https://imagej.net/scripting/")); + menu.addSeparator(); + menu.add(helpMenuItem("ImageJ Notebook Tutorials", "https://github.com/imagej/tutorials#readme")); + return menu; + } + + private JMenuItem helpMenuItem(final String label, final String url) { + final JMenuItem item = new JMenuItem(label); + item.addActionListener(e -> GuiUtils.openURL(TextEditor.this, platformService, url)); + return item; + } + + protected void applyConsolePopupMenu(final JTextArea textArea) { + final JPopupMenu popup = new JPopupMenu(); + textArea.setComponentPopupMenu(popup); + final String scope = ((textArea == errorScreen) ? "Errors..." : "Outputs..."); + JMenuItem jmi = new JMenuItem("Search " + scope); + popup.add(jmi); + jmi.addActionListener(e -> { + findDialog.setLocationRelativeTo(this); + findDialog.setRestrictToConsole(true); + final String text = textArea.getSelectedText(); + if (text != null) findDialog.setSearchPattern(text); + // Ensure the right pane is visible in case search is being + // triggered by CmdPalette + if (textArea == errorScreen && !getTab().showingErrors) + getTab().showErrors(); + else if (getTab().showingErrors) + getTab().showOutput(); + findDialog.show(false); + }); + cmdPalette.register(jmi, scope); + jmi = new JMenuItem("Search Script for Selected Text..."); + popup.add(jmi); + jmi.addActionListener(e -> { + final String text = textArea.getSelectedText(); + if (text == null) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + } else { + findDialog.setLocationRelativeTo(this); + findDialog.setRestrictToConsole(false); + if (text != null) findDialog.setSearchPattern(text); + findDialog.show(false); + } + }); + popup.addSeparator(); + + jmi = new JMenuItem("Clear Selected Text"); + popup.add(jmi); + jmi.addActionListener(e -> { + if (textArea.getSelectedText() == null) + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + else + textArea.replaceSelection(""); + }); + final DefaultHighlighter highlighter = (DefaultHighlighter)textArea.getHighlighter(); + highlighter.setDrawsLayeredHighlights(false); + jmi = new JMenuItem("Highlight Selected Text"); + popup.add(jmi); + jmi.addActionListener(e -> { + try { + final Color taint = (textArea == errorScreen) ? Color.RED : textArea.getSelectionColor(); + final Color color = ErrorParser.averageColors(textArea.getBackground(), taint); + final DefaultHighlightPainter painter = new DefaultHighlighter.DefaultHighlightPainter(color); + textArea.getHighlighter().addHighlight(textArea.getSelectionStart(), textArea.getSelectionEnd(), painter); + textArea.setCaretPosition(textArea.getSelectionEnd()); + textArea.getHighlighter(); + } catch (final BadLocationException ignored) { + UIManager.getLookAndFeel().provideErrorFeedback(textArea); + } + }); + jmi = new JMenuItem("Clear Highlights"); + popup.add(jmi); + jmi.addActionListener(e -> textArea.getHighlighter().removeAllHighlights()); + cmdPalette.register(jmi, scope); + popup.addSeparator(); + final JCheckBoxMenuItem jmc = new JCheckBoxMenuItem("Wrap Lines"); + popup.add(jmc); + jmc.addActionListener( e -> textArea.setLineWrap(jmc.isSelected())); + cmdPalette.register(jmc, scope); + } + + private static Collection assembleFlatFileCollection(final Collection collection, final File[] files) { + if (files == null) return collection; // can happen while pressing 'Esc'!? + for (final File file : files) { + if (file == null || isBinary(file)) + continue; + else if (file.isDirectory()) + assembleFlatFileCollection(collection, file.listFiles()); + else //if (!file.isHidden()) + collection.add(file); + } + return collection; + } + + protected static class GuiUtils { + + private GuiUtils() { + } + + static void error(final Component parent, final String message) { + JOptionPane.showMessageDialog(parent, message, "Error", JOptionPane.ERROR_MESSAGE); + } + + static void warn(final Component parent, final String message) { + JOptionPane.showMessageDialog(parent, message, "Warning", JOptionPane.WARNING_MESSAGE); + } + + static void info(final Component parent, final String message, final String title) { + JOptionPane.showMessageDialog(parent, message, title, JOptionPane.INFORMATION_MESSAGE); + } + + static boolean confirm(final Component parent, final String message, final String title, + final String yesButtonLabel) { + return JOptionPane.showConfirmDialog(parent, message, title, JOptionPane.YES_NO_OPTION) == + JOptionPane.YES_OPTION; + } + + static void showHTMLDialog(final Component parent, final String title, final String htmlContents) { + final JTextPane f = new JTextPane(); + f.setContentType("text/html"); + f.setEditable(false); + f.setBackground(null); + f.setBorder(null); + f.setText(htmlContents); + f.setCaretPosition(0); + final JScrollPane sp = new JScrollPane(f); + final JOptionPane pane = new JOptionPane(sp); + final JDialog dialog = pane.createDialog(parent, title); + dialog.setResizable(true); + dialog.pack(); + dialog.setPreferredSize( + new Dimension( + (int) Math.min(parent.getWidth() * .5, pane.getPreferredSize().getWidth() + (3 * sp.getVerticalScrollBar().getWidth())), + (int) Math.min(parent.getHeight() * .8, pane.getPreferredSize().getHeight()))); + dialog.pack(); + pane.addPropertyChangeListener(JOptionPane.VALUE_PROPERTY, ignored -> { + dialog.dispose(); + }); + dialog.setModal(false); + dialog.setLocationRelativeTo(parent); + dialog.setVisible(true); + } + + static String getString(final Component parent, final String message, final String title) { + return (String) JOptionPane.showInputDialog(parent, message, title, JOptionPane.QUESTION_MESSAGE); + } + + static void runSearchQueryInBrowser(final Component parentComponent, final PlatformService platformService, + final String query) { + String url; + try { + url = "https://forum.image.sc/search?q=" + URLEncoder.encode(query, StandardCharsets.UTF_8.toString()); + } catch (final Exception ignored) { + url = query.trim().replace(" ", "%20"); + } + openURL(parentComponent, platformService, url); + } + + static void openURL(final Component parentComponent, final PlatformService platformService, final String url) { + try { + platformService.open(new URL(url)); + } catch (final Exception ignored) { + // Error message with selectable text + final JTextPane f = new JTextPane(); + f.setContentType("text/html"); + f.setText("Web page could not be open. Please visit
" + url + "
using your web browser."); + f.setEditable(false); + f.setBackground(null); + f.setBorder(null); + JOptionPane.showMessageDialog(parentComponent, f, "Error", JOptionPane.ERROR_MESSAGE); + } + } + + static void openTerminal(final File pwd) throws IOException, InterruptedException { + final String[] wrappedCommand; + final File dir = (pwd.isDirectory()) ? pwd : pwd.getParentFile(); + if (PlatformUtils.isWindows()) { + // this should probably be replaced with powershell!? + wrappedCommand = new String[] { "cmd", "/c", "start", "/wait", "cmd.exe", "/K" }; + } else if (PlatformUtils.isLinux()) { + // this will likely only work on debian-based distros + wrappedCommand = new String[] { "/usr/bin/x-terminal-emulator" }; + } else if (PlatformUtils.isMac()) { + // On MacOS ProcessBuilder#directory() fails!? so we'll use applescript :) + wrappedCommand = new String[] { "osascript", "-e", // + "tell application \"Terminal\" to (do script \"cd '" + dir.getAbsolutePath() + "';clear\")" }; + } else { + throw new IllegalArgumentException("Unsupported OS"); + } + final ProcessBuilder pb = new ProcessBuilder(wrappedCommand); + pb.directory(dir); // does nothing on macOS !? + pb.start(); + } + + static void addMenubarSeparator(final JMenu menu, final String header) { + if (menu.getMenuComponentCount() > 0) { + menu.addSeparator(); + } + try { + final JLabel label = new JLabel(" "+ header); + label.setEnabled(false); + label.setForeground(getDisabledComponentColor()); + menu.add(label); + } catch (final Exception ignored) { + // do nothing + } + } + + static void addPopupMenuSeparator(final JPopupMenu menu, final String header) { + if (menu.getComponentCount() > 1) { + menu.addSeparator(); + } + final JLabel label = new JLabel(header); + // label.setHorizontalAlignment(SwingConstants.LEFT); + label.setEnabled(false); + label.setForeground(getDisabledComponentColor()); + menu.add(label); + + } + + static Color getDisabledComponentColor() { + try { + return UIManager.getColor("MenuItem.disabledForeground"); + } catch (final Exception ignored) { + return Color.GRAY; + } + } + + static boolean isDarkLaF() { + return FlatLaf.isLafDark() || isDark(new JLabel().getBackground()); + } + + static boolean isDark(final Color c) { + // see https://stackoverflow.com/a/3943023 + return (c.getRed() * 0.299 + c.getGreen() * 0.587 + c.getBlue() * 0.114) < 186; + } + + static void collapseAllTreeNodes(final JTree tree) { + final int row1 = (tree.isRootVisible()) ? 1 : 0; + for (int i = row1; i < tree.getRowCount(); i++) + tree.collapseRow(i); + } + + static void expandAllTreeNodes(final JTree tree) { + for (int i = 0; i < tree.getRowCount(); i++) + tree.expandRow(i); + } + + /* Tweaks for tabbed pane */ + static JTabbedPane getJTabbedPane() { + final JTabbedPane tabbed = new JTabbedPane(); + final JPopupMenu popup = new JPopupMenu(); + tabbed.setComponentPopupMenu(popup); + final ButtonGroup bGroup = new ButtonGroup(); + for (final String pos : new String[] { "Top", "Left", "Bottom", "Right" }) { + final JMenuItem jcbmi = new JCheckBoxMenuItem("Place on " + pos, "Top".equals(pos)); + jcbmi.addItemListener(e -> { + switch (pos) { + case "Top": + tabbed.setTabPlacement(JTabbedPane.TOP); + break; + case "Bottom": + tabbed.setTabPlacement(JTabbedPane.BOTTOM); + break; + case "Left": + tabbed.setTabPlacement(JTabbedPane.LEFT); + break; + case "Right": + tabbed.setTabPlacement(JTabbedPane.RIGHT); + break; + } + }); + bGroup.add(jcbmi); + popup.add(jcbmi); + } + tabbed.addMouseWheelListener(e -> { + // https://stackoverflow.com/a/38463104 + final JTabbedPane pane = (JTabbedPane) e.getSource(); + final int units = e.getWheelRotation(); + final int oldIndex = pane.getSelectedIndex(); + final int newIndex = oldIndex + units; + if (newIndex < 0) + pane.setSelectedIndex(0); + else if (newIndex >= pane.getTabCount()) + pane.setSelectedIndex(pane.getTabCount() - 1); + else + pane.setSelectedIndex(newIndex); + }); + return tabbed; + } + } + + static class TextFieldWithPlaceholder extends JTextField { + + private static final long serialVersionUID = 1L; + private String placeholder; + + void setPlaceholder(final String placeholder) { + this.placeholder = placeholder; + update(getGraphics()); + } + + Font getPlaceholderFont() { + return getFont().deriveFont(Font.ITALIC); + } + + String getPlaceholder() { + return placeholder; + } + + @Override + protected void paintComponent(final java.awt.Graphics g) { + super.paintComponent(g); + if (getText().isEmpty()) { + final Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2.setColor(getDisabledTextColor()); + g2.setFont(getPlaceholderFont()); + g2.drawString(getPlaceholder(), getInsets().left, + g2.getFontMetrics().getHeight() + getInsets().top - getInsets().bottom); + g2.dispose(); + } + } + + } + } diff --git a/src/main/java/org/scijava/ui/swing/script/TextEditorTab.java b/src/main/java/org/scijava/ui/swing/script/TextEditorTab.java index 60d715b0..8b62c471 100644 --- a/src/main/java/org/scijava/ui/swing/script/TextEditorTab.java +++ b/src/main/java/org/scijava/ui/swing/script/TextEditorTab.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -29,8 +29,8 @@ package org.scijava.ui.swing.script; +import java.awt.BorderLayout; import java.awt.Dimension; -import java.awt.Font; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.datatransfer.DataFlavor; @@ -49,7 +49,6 @@ import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JLabel; -import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSplitPane; @@ -57,6 +56,7 @@ import javax.swing.SwingUtilities; import javax.swing.text.JTextComponent; +import org.fife.ui.rsyntaxtextarea.ErrorStrip; import org.scijava.ui.swing.script.TextEditor.Executer; /** @@ -67,7 +67,6 @@ public class TextEditorTab extends JSplitPane { private static final String DOWN_ARROW = "\u25BC"; - private static final String RIGHT_ARROW = "\u25B6"; protected final EditorPane editorPane; @@ -81,6 +80,7 @@ public class TextEditorTab extends JSplitPane { private final JButton runit, batchit, killit, toggleErrors, switchSplit; private final JCheckBox incremental; private final JSplitPane screenAndPromptSplit; + private int screenAndPromptSplitDividerLocation; private final TextEditor textEditor; private DropTarget dropTarget; @@ -89,21 +89,22 @@ public class TextEditorTab extends JSplitPane { public TextEditorTab(final TextEditor textEditor) { super(JSplitPane.VERTICAL_SPLIT); super.setResizeWeight(350.0 / 430.0); - this.setOneTouchExpandable(true); + setOneTouchExpandable(true); this.textEditor = textEditor; editorPane = new EditorPane(); dropTargetListener = new DropTargetListener() { @Override - public void dropActionChanged(DropTargetDragEvent arg0) {} + public void dropActionChanged(final DropTargetDragEvent arg0) {} @Override - public void drop(DropTargetDropEvent e) { + public void drop(final DropTargetDropEvent e) { if (e.getDropAction() != DnDConstants.ACTION_COPY) { e.rejectDrop(); return; } - Transferable t = e.getTransferable(); + e.acceptDrop(DnDConstants.ACTION_COPY_OR_MOVE); // fix for InvalidDnDOperationException: No drop current + final Transferable t = e.getTransferable(); if (!t.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return; try { final Object o = t.getTransferData(DataFlavor.javaFileListFlavor); @@ -111,7 +112,7 @@ public void drop(DropTargetDropEvent e) { final List list = (List) o; if (list.isEmpty()) return; String path; - Object first = list.get(0); + final Object first = list.get(0); if (first instanceof String) path = (String) first; else if (first instanceof File) path = ((File) first).getAbsolutePath(); else return; @@ -119,29 +120,31 @@ public void drop(DropTargetDropEvent e) { // Point p = e.getLocation(); // ... but it is more predictable (less surprising) to insert where the caret is: editorPane.getRSyntaxDocument().insertString(editorPane.getCaretPosition(), path, null); - } catch (Exception ex) { + } catch (final Exception ex) { ex.printStackTrace(); } } @Override - public void dragOver(DropTargetDragEvent e) { + public void dragOver(final DropTargetDragEvent e) { if (e.getDropAction() != DnDConstants.ACTION_COPY) e.rejectDrag(); } @Override - public void dragExit(DropTargetEvent e) {} + public void dragExit(final DropTargetEvent e) {} @Override - public void dragEnter(DropTargetDragEvent e) { + public void dragEnter(final DropTargetDragEvent e) { if (e.getDropAction() != DnDConstants.ACTION_COPY) e.rejectDrag(); } }; dropTarget = new DropTarget(editorPane, DnDConstants.ACTION_COPY, dropTargetListener); + // tweaks for console screen.setEditable(false); - screen.setLineWrap(true); - screen.setFont(new Font("Courier", Font.PLAIN, 12)); + screen.setLineWrap(false); + screen.setFont(getEditorPane().getFont()); + textEditor.applyConsolePopupMenu(screen); final JPanel bottom = new JPanel(); bottom.setLayout(new GridBagLayout()); @@ -151,44 +154,29 @@ public void dragEnter(DropTargetDragEvent e) { bc.gridy = 0; bc.weightx = 0; bc.weighty = 0; - bc.anchor = GridBagConstraints.NORTHWEST; - bc.fill = GridBagConstraints.NONE; + bc.anchor = GridBagConstraints.CENTER; + bc.fill = GridBagConstraints.HORIZONTAL; runit = new JButton("Run"); - runit.setToolTipText("control + R"); - runit.addActionListener(new ActionListener() { - - @Override - public void actionPerformed(final ActionEvent ae) { - textEditor.runText(); - } - }); + runit.setToolTipText("Control+R, F5, or F11"); + textEditor.cmdPalette.register(runit, "Interpreter"); + runit.addActionListener(ae -> textEditor.runText()); bottom.add(runit, bc); bc.gridx = 1; batchit = new JButton("Batch"); - batchit.addActionListener(new ActionListener() { - - @Override - public void actionPerformed(final ActionEvent ae) { - textEditor.runBatch(); - } - }); + batchit.setToolTipText("Requires at least one @File SciJava parameter to be declared"); + batchit.addActionListener(e -> textEditor.runBatch()); bottom.add(batchit, bc); bc.gridx = 2; killit = new JButton("Kill"); killit.setEnabled(false); - killit.addActionListener(new ActionListener() { - - @Override - public void actionPerformed(final ActionEvent ae) { - kill(); - } - }); + killit.addActionListener(ae -> kill()); bottom.add(killit, bc); - + bc.gridx = 3; - incremental = new JCheckBox("persistent"); + incremental = new JCheckBox("REPL"); + textEditor.cmdPalette.register(incremental, "Interpreter"); incremental.setEnabled(true); incremental.setSelected(false); bottom.add(incremental, bc); @@ -211,15 +199,19 @@ public void actionPerformed(final ActionEvent ae) { bc.weightx = 0; bc.anchor = GridBagConstraints.NORTHEAST; final JButton clear = new JButton("Clear"); - clear.addActionListener(ae -> getScreen().setText("")); + clear.addActionListener(ae -> { + getScreen().setText(""); + if (showingErrors) editorPane.getErrorHighlighter().reset(); + }); bottom.add(clear, bc); - + textEditor.cmdPalette.register(clear, "Console"); + bc.gridx = 7; switchSplit = new JButton(RIGHT_ARROW); switchSplit.setToolTipText("Switch location"); switchSplit.addActionListener(new ActionListener() { @Override - public void actionPerformed(ActionEvent e) { + public void actionPerformed(final ActionEvent e) { if (DOWN_ARROW.equals(switchSplit.getText())) { TextEditorTab.this.setOrientation(JSplitPane.VERTICAL_SPLIT); } else { @@ -227,7 +219,7 @@ public void actionPerformed(ActionEvent e) { } // Keep prompt collapsed if not in use if (!incremental.isSelected()) { - SwingUtilities.invokeLater(() -> screenAndPromptSplit.setDividerLocation(1.0)); + setREPLVisible(false); } } }); @@ -240,15 +232,13 @@ public void actionPerformed(ActionEvent e) { bc.weightx = 1; bc.weighty = 1; bc.gridwidth = 8; - screen.setEditable(false); - screen.setLineWrap(true); - final Font font = new Font("Courier", Font.PLAIN, 12); - screen.setFont(font); scroll = new JScrollPane(screen); bottom.add(scroll, bc); - + prompt.setEnabled(false); - + prompt.setFont(getEditorPane().getFont()); + prompt.setTabSize(editorPane.getTabSize()); + final JPanel prompt_panel = new JPanel(); prompt_panel.setMinimumSize(new Dimension(0, 0)); prompt_panel.setVisible(false); @@ -277,22 +267,19 @@ public void actionPerformed(ActionEvent e) { bc.gridx = 3; final JButton prompt_help = new JButton("?"); - prompt_help.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent a) { - final String msg = "This REPL (read-evaluate-print-loop) parses " + textEditor.getCurrentLanguage().getLanguageName() + " code.\n\n" - + "Key bindings:\n" - + "* enter: evaluate code\n" - + "* shift+enter: add line break (also alt-enter and meta-enter)\n" - + "* page UP or ctrl+p: show previous entry in the history\n" - + "* page DOWN or ctrl+n: show next entry in the history\n" - + "\n" - + "If 'Use arrow keys' is checked, then up/down arrows work like page UP/DOWN,\n" - + "and shift+up/down arrow work like arrow keys before for caret movement\n" - + "within a multi-line prompt." - ; - JOptionPane.showMessageDialog(textEditor, msg, "REPL help", JOptionPane.INFORMATION_MESSAGE); - } + prompt_help.addActionListener(a -> { + final String msg = "This REPL (Read-Evaluate-Print-Loop) parses " + textEditor.getCurrentLanguage().getLanguageName() + " code.\n\n" + + "Key bindings:\n" + + " [Enter]: Evaluate code\n" + + " [Shift+Enter]: Add line break (also alt-enter and meta-enter)\n" + + " [Page UP] or [Ctrl+P]: Show previous entry in the history\n" + + " [Page DOWN] or [Ctrl+N]: Show next entry in the history\n" + + "\n" + + "If 'Use arrow keys' is checked, then up/down arrows work like\n" + + "Page UP/DOWN, and Shift+up/down arrows work like arrow\n" + + "keys before for caret movement within a multi-line prompt." + ; + textEditor.info(msg, "REPL Help"); }); prompt_panel.add(prompt_help, bc); @@ -304,12 +291,11 @@ public void actionPerformed(ActionEvent a) { bc.weighty = 1; bc.gridwidth = 4; prompt_panel.add(prompt, bc); - + incremental.addActionListener(ae -> { if (incremental.isSelected() && null == textEditor.getCurrentLanguage()) { incremental.setSelected(false); - JOptionPane.showMessageDialog(TextEditorTab.this, - "Select a language first!"); + textEditor.error("Select a language first!"); return; } textEditor.setIncremental(incremental.isSelected()); @@ -320,10 +306,20 @@ public void actionPerformed(ActionEvent a) { }); screenAndPromptSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, bottom, prompt_panel); - - super.setLeftComponent(editorPane.wrappedInScrollbars()); + + // Enable ErrorSrip à la Eclipse. This will keep track of lines with 'Mark All' + // occurrences as well as lines associated with ParserNotice.Level.WARNING and + // ParserNotice.Level.ERROR. NB: As is, the end of the strip corresponds to the + // last line of text in the text area: E.g., for a text area with just 3 lines, + // line 2 will be marked at the strip's half height + final ErrorStrip es = new ErrorStrip(editorPane); + es.setShowMarkAll(true); + es.setShowMarkedOccurrences(true); + final JPanel holder = new JPanel(new BorderLayout()); + holder.add(editorPane.wrappedInScrollbars()); + holder.add(es, BorderLayout.LINE_END); + super.setLeftComponent(holder); super.setRightComponent(screenAndPromptSplit); - screenAndPromptSplit.setDividerLocation(600); screenAndPromptSplit.setDividerLocation(1.0); // Persist Script Editor layout whenever split pane divider is adjusted. @@ -332,12 +328,32 @@ public void actionPerformed(ActionEvent a) { textEditor.saveWindowSizeToPrefs(); }); } - + // Package-private JSplitPane getScreenAndPromptSplit() { return screenAndPromptSplit; } + void setREPLVisible(final boolean visible) { + SwingUtilities.invokeLater(() -> { + if (visible) { + // If stashed location of divider is invalid, set divider to half of panel's height and re-stash + if (screenAndPromptSplitDividerLocation <= 0 + || screenAndPromptSplitDividerLocation <= getScreenAndPromptSplit().getMinimumDividerLocation() + || screenAndPromptSplitDividerLocation >= getScreenAndPromptSplit().getMaximumDividerLocation()) { + getScreenAndPromptSplit().setDividerLocation(.5d); + screenAndPromptSplitDividerLocation = getScreenAndPromptSplit().getDividerLocation(); + } else { + getScreenAndPromptSplit().setDividerLocation(screenAndPromptSplitDividerLocation); + } + } else { // collapse to bottom + screenAndPromptSplitDividerLocation = getScreenAndPromptSplit().getDividerLocation(); + getScreenAndPromptSplit().setDividerLocation(1f); + } + incremental.setSelected(visible); + }); + } + @Override public void setOrientation(final int orientation) { super.setOrientation(orientation); @@ -365,6 +381,8 @@ public void restore() { runit.setEnabled(true); killit.setEnabled(false); setExecutor(null); + if(incremental.isSelected()) + prompt.requestFocusInWindow(); }); } diff --git a/src/main/java/org/scijava/ui/swing/script/TokenFunctions.java b/src/main/java/org/scijava/ui/swing/script/TokenFunctions.java index e9ee25cf..ff1e0d81 100644 --- a/src/main/java/org/scijava/ui/swing/script/TokenFunctions.java +++ b/src/main/java/org/scijava/ui/swing/script/TokenFunctions.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/VarsPane.java b/src/main/java/org/scijava/ui/swing/script/VarsPane.java index c37022b8..b3a0fcb3 100644 --- a/src/main/java/org/scijava/ui/swing/script/VarsPane.java +++ b/src/main/java/org/scijava/ui/swing/script/VarsPane.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java index 1cad39e8..c52fc441 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -30,6 +30,10 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.net.URL; import java.net.URLConnection; import java.util.ArrayList; @@ -46,9 +50,13 @@ import java.util.regex.Pattern; import java.util.stream.Stream; +import org.fife.ui.autocomplete.BasicCompletion; +import org.fife.ui.autocomplete.Completion; +import org.fife.ui.autocomplete.CompletionProvider; + public class ClassUtil { - static private final String scijava_javadoc_URL = "https://javadoc.scijava.org/"; // with ending slash + static final String scijava_javadoc_URL = "https://javadoc.scijava.org/"; // with ending slash /** Cache of class names vs list of URLs found in the pom.xml files of their contaning jar files, if any. */ static private final Map class_urls = new HashMap<>(); @@ -335,4 +343,135 @@ static public final ArrayList findSimpleClassNamesStartingWith(final Str } return matches; } + + private static String getJavaDocLink(final Class c) { + final String name = c.getCanonicalName(); + final String pkg = getDocPackage(name); + if (pkg == null) return name; + final String url = String.format("%s%s/index.html?%s.html", scijava_javadoc_URL, pkg, name.replace(".", "/")); + return String.format("%s", url, name); + } + + private static String getDocPackage(final String classCanonicalName) { + //TODO: Do this programmatically + if (classCanonicalName.startsWith("ij.")) + return "ImageJ1"; + else if (classCanonicalName.startsWith("sc.fiji")) + return "Fiji"; + else if (classCanonicalName.startsWith("net.imagej")) + return "ImageJ"; + else if (classCanonicalName.startsWith("net.imglib2")) + return "ImgLib2"; + else if (classCanonicalName.startsWith("org.scijava")) + return "SciJava"; + else if (classCanonicalName.startsWith("loci.formats")) + return "Bio-Formats"; + if (classCanonicalName.startsWith("java.")) + return "Java8"; + else if (classCanonicalName.startsWith("sc.iview")) + return "SciView"; + else if (classCanonicalName.startsWith("weka.")) + return "Weka"; + else if (classCanonicalName.startsWith("inra.ijpb")) + return "MorphoLibJ"; + return null; + } + + /** + * Assembles an HTML-formatted auto-completion summary with functional + * hyperlinks + * + * @param field the field being documented + * @param c the class being documented. Expected to be documented at the + * Scijava API documentation portal. + * @return the completion summary + */ + protected static String getSummaryCompletion(final Field field, final Class c) { + final StringBuffer summary = new StringBuffer(); + summary.append("").append(field.getName()).append(""); + summary.append(" (").append(field.getType().getName()).append(")"); + summary.append("
"); + summary.append("
Defined in:"); + summary.append("
").append(getJavaDocLink(c)); + summary.append("
"); + return summary.toString(); + } + + /** + * Assembles an HTML-formatted auto-completion summary with functional + * hyperlinks + * + * @param method the method being documented + * @param c the class being documented. Expected to be documented at the + * Scijava API documentation portal. + * @return the completion summary + */ + protected static String getSummaryCompletion(final Method method, final Class c) { + final StringBuffer summary = new StringBuffer(); + final StringBuffer replacementHeader = new StringBuffer(method.getName()); + final int bIndex = replacementHeader.length(); // remember '(' position + replacementHeader.append("("); + final Parameter[] params = method.getParameters(); + if (params.length > 0) { + for (final Parameter parameter : params) { + replacementHeader.append(parameter.getType().getSimpleName()).append(" ").append(parameter.getName()).append(", "); + } + replacementHeader.setLength(replacementHeader.length() - 2); // remove trailing ', '; + } + replacementHeader.append(")"); + replacementHeader.replace(bIndex, bIndex + 1, "("); // In header, highlight only method name for extra contrast + summary.append("").append(replacementHeader); + summary.append("
"); + summary.append("
Returns:"); + summary.append("
").append(method.getReturnType().getSimpleName()); + summary.append("
Defined in:"); + summary.append("
").append(getJavaDocLink(c)); + summary.append("
"); + return summary.toString(); + } + + /** + * Assembles an HTML-formatted auto-completion summary with functional + * hyperlinks + * + * @param constructor the constructor being documented + * @param c the class being documented. Expected to be documented at the + * Scijava API documentation portal. + * @return the completion summary + */ + protected static String getSummaryCompletion(final Constructor constructor, final Class c) { + final StringBuffer summary = new StringBuffer(); + final StringBuffer replacementHeader = new StringBuffer(c.getSimpleName()); + final int bIndex = replacementHeader.length(); // remember '(' position + replacementHeader.append("("); + final Parameter[] params = constructor.getParameters(); + if (params.length > 0) { + for (final Parameter parameter : params) { + replacementHeader.append(parameter.getType().getSimpleName()).append(" ").append(parameter.getName()).append(", "); + } + replacementHeader.setLength(replacementHeader.length() - 2); // remove trailing ', '; + } + replacementHeader.append(")"); + replacementHeader.replace(bIndex, bIndex + 1, "
("); // In header, highlight only method name for extra contrast + summary.append("").append(replacementHeader); + summary.append("
"); + summary.append("
Intantiates:"); + summary.append("
").append(c.getSimpleName()); + summary.append("
Defined in:"); + summary.append("
").append(getJavaDocLink(c)); + summary.append("
"); + return summary.toString(); + } + + static List classUnavailableCompletions(final CompletionProvider provider, final String pre) { + // placeholder completions to warn users class was not available (repeated to force pop-up display) + final List list = new ArrayList<>(); + final String summary = "Class not found or invalid import. See " + + String.format("SciJavaDocs", scijava_javadoc_URL) + + " or search for help"; + list.add(new BasicCompletion(provider, pre + "?", null, summary)); + list.add(new BasicCompletion(provider, pre + "?", null, summary)); + return list; + } + } diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java new file mode 100644 index 00000000..15fad2e4 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java @@ -0,0 +1,124 @@ +/*- + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.ui.swing.script.autocompletion; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.fife.ui.autocomplete.AbstractCompletion; +import org.fife.ui.autocomplete.BasicCompletion; +import org.fife.ui.autocomplete.CompletionProvider; + +public class CompletionText { + + private String replacementText; + private String description; + private String summary; + private List method_args = Collections.emptyList(); + private String method_returnType = null; + + public CompletionText(final String replacementText) { + this(replacementText, (String)null, (String)null); + } + + public CompletionText(final String replacementText, final String summary, final String description) { + this.replacementText = replacementText; + this.summary = summary; + this.description = description; + } + + public CompletionText(final String replacementText, final Class c, final Field f) { + this(replacementText, ClassUtil.getSummaryCompletion(f, c), null); + } + + public CompletionText(final String replacementText, final Class c, final Method m) { + this(replacementText, ClassUtil.getSummaryCompletion(m, c), null); + this.method_args = Arrays.asList(m.getParameters()); + this.method_returnType = m.getReturnType().getCanonicalName(); + } + + public CompletionText(final String replacementText, final Class c, final Constructor constructor) { + this(replacementText, ClassUtil.getSummaryCompletion(constructor, c), null); + this.method_args = Arrays.asList(constructor.getParameters()); + this.method_returnType = c.getCanonicalName(); + } + + public String getReplacementText() { + return replacementText; + } + + public String getDescription() { + return description; + } + + public String getSummary() { + return summary; + } + + public List getMethodArgs() { + return method_args; + } + + public String getReturnType() { + return method_returnType; + } + + public AbstractCompletion getCompletion(final CompletionProvider provider, final String replacementText) { + return new BasicCompletion(provider, replacementText, description, summary); + } + + public AbstractCompletion getCompletion(final CompletionProvider provider, final String replacementText, final int relevance) { + final BasicCompletion bc = new BasicCompletion(provider, replacementText, description, summary); + bc.setRelevance(relevance); + return bc; + } + + public void setReplacementText(final String replacementText) { + this.replacementText = replacementText; + } + + public void setDescription(final String description) { + this.description = description; + } + + public void setSummary(final String summary) { + this.summary = summary; + } + + @Override + public String toString() { + return replacementText + " | " + description + " | " + summary; + } + +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletion.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletion.java index 5d989228..67381fb8 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletion.java +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletion.java @@ -1,3 +1,31 @@ +/*- + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ package org.scijava.ui.swing.script.autocompletion; public interface ImportCompletion diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletionImpl.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletionImpl.java index ac6c415c..c6568590 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletionImpl.java +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportCompletionImpl.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportFormat.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportFormat.java index a69ad82d..d76482e6 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportFormat.java +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/ImportFormat.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonAutoCompletion.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonAutoCompletion.java deleted file mode 100644 index adfbcee4..00000000 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonAutoCompletion.java +++ /dev/null @@ -1,154 +0,0 @@ -/*- - * #%L - * Script Editor and Interpreter for SciJava script languages. - * %% - * Copyright (C) 2009 - 2020 SciJava developers. - * %% - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - * #L% - */ -package org.scijava.ui.swing.script.autocompletion; - -import java.util.HashMap; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import javax.swing.text.BadLocationException; - -import org.fife.ui.autocomplete.AutoCompletion; -import org.fife.ui.autocomplete.Completion; -import org.fife.ui.autocomplete.CompletionProvider; -import org.scijava.ui.swing.script.EditorPane; - -public class JythonAutoCompletion extends AutoCompletion { - - public JythonAutoCompletion(final CompletionProvider provider) { - super(provider); - this.setShowDescWindow(true); - } - - static private final Pattern importPattern = Pattern.compile("^(from[ \\t]+([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+|)import[ \\t]+([a-zA-Z_][a-zA-Z0-9_]*[ \\ta-zA-Z0-9_,]*)[ \\t]*([\\\\]*|)[ \\t]*(#.*|)$"), - tripleQuotePattern = Pattern.compile("\"\"\""); - - static public class Import { - final public String className, - alias; // same as simple class name, or the alias from "as " - final public int lineNumber; - - public Import(final String className, final String alias, final int lineNumber) { - this.className = className; - this.alias = null != alias ? alias : className.substring(className.lastIndexOf('.') + 1); - this.lineNumber = lineNumber; - } - - // E.g. handle "ImageJFunctions as IL" -> ["ImageJFunctions", "as", "IL"] - public Import(final String packageName, final String[] parts, final int lineNumber) { - this(packageName + "." + parts[0], 3 == parts.length ? parts[2] : null, lineNumber); - } - } - - static public final HashMap findImportedClasses(final String text) { - final HashMap importedClasses = new HashMap<>(); - String packageName = ""; - boolean endingBackslash = false; - boolean insideTripleQuotes = false; - - // Scan the whole file for imports - final String[] lines = text.split("\n"); - for (int i=0; i -1) importLine = importLine.substring(0, backslash); - else { - final int sharp = importLine.lastIndexOf('#'); - if (sharp > -1) importLine = importLine.substring(0, sharp); - } - for (final String simpleClassName : importLine.split(",")) { - final Import im = new Import(packageName, simpleClassName.trim().split("\\s"), i); - importedClasses.put(im.alias, im); - } - endingBackslash = -1 != backslash; // otherwise there is another line with classes of the same package - continue; - } - final Matcher m = importPattern.matcher(line); - if (m.find()) { - packageName = null == m.group(2) ? "" : m.group(2); - for (final String simpleClassName : m.group(3).split(",")) { - final Import im = new Import(packageName, simpleClassName.trim().split("\\s"), i); - importedClasses.put(im.alias, im); - } - endingBackslash = null != m.group(4) && m.group(4).length() > 0 && '\\' == m.group(4).charAt(0); - } - } - - return importedClasses; - } - - @Override - protected void insertCompletion(final Completion c, final boolean typedParamListStartChar) { - if (c instanceof ImportCompletion) { - final EditorPane editor = (EditorPane) super.getTextComponent(); - editor.beginAtomicEdit(); - try { - super.insertCompletion(c, typedParamListStartChar); - final ImportCompletion cc = (ImportCompletion)c; - final HashMap importedClasses = findImportedClasses(editor.getText()); - // Insert import statement after the last import, if not there already - for (final Import im : importedClasses.values()) { - if (im.className.contentEquals(cc.getClassName())) - return; // don't insert - } - try { - final int insertAtLine = 0 == importedClasses.size() ? 0 - : importedClasses.values().stream().map(im -> im.lineNumber).reduce(Math::max).get(); - editor.insert(cc.getImportStatement() + "\n", editor.getLineStartOffset(0 == insertAtLine ? 0 : insertAtLine + 1)); - } catch (BadLocationException e) { - e.printStackTrace(); - } - } finally { - editor.endAtomicEdit(); - } - } else { - super.insertCompletion(c, typedParamListStartChar); - } - } -} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonAutocompletionProvider.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonAutocompletionProvider.java deleted file mode 100644 index 1be15159..00000000 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonAutocompletionProvider.java +++ /dev/null @@ -1,221 +0,0 @@ -/*- - * #%L - * Script Editor and Interpreter for SciJava script languages. - * %% - * Copyright (C) 2009 - 2020 SciJava developers. - * %% - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - * #L% - */ -package org.scijava.ui.swing.script.autocompletion; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Vector; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.swing.text.BadLocationException; -import javax.swing.text.JTextComponent; - -import org.fife.ui.autocomplete.BasicCompletion; -import org.fife.ui.autocomplete.Completion; -import org.fife.ui.autocomplete.DefaultCompletionProvider; -import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; - -public class JythonAutocompletionProvider extends DefaultCompletionProvider { - - static private final Vector autocompletion_listeners = new Vector<>(); - - static { - try { - // Load the class so that it adds itself to the autocompletion_listeners - Class.forName("sc.fiji.jython.autocompletion.JythonAutoCompletions"); - } catch (Throwable t) { - System.out.println("WARNING did not load JythonAutoCompletions"); - } - } - - private final RSyntaxTextArea text_area; - private final ImportFormat formatter; - - public JythonAutocompletionProvider(final RSyntaxTextArea text_area, final ImportFormat formatter) { - this.text_area = text_area; - this.formatter = formatter; - new Thread(ClassUtil::ensureCache).start(); - } - - /** - * Override parent implementation to allow letters, digits, the period and a space, to be able to match e.g.: - * - * "from " - * "from ij" - * "from ij.Im" - * etc. - * - * @param c - */ - @Override - public boolean isValidChar(final char c) { - return Character.isLetterOrDigit(c) || '.' == c || ' ' == c; - } - - static private final Pattern - fromImport = Pattern.compile("^((from|import)[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)$"), - fastImport = Pattern.compile("^(from[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+$"), - importStatement = Pattern.compile("^((from[ \\t]+([a-zA-Z0-9._]+)[ \\t]+|[ \\t]*)import[ \\t]+)([a-zA-Z0-9_., \\t]*)$"), - simpleClassName = Pattern.compile("^(.*[ \\t]+|)([A-Z_][a-zA-Z0-9_]+)$"), - staticMethodOrField = Pattern.compile("^((.*[ \\t]+|)([A-Z_][a-zA-Z0-9_]*)\\.)([a-zA-Z0-9_]*)$"); - - private final List asCompletionList(final Stream stream, final String pre) { - return stream - .map((s) -> new BasicCompletion(JythonAutocompletionProvider.this, pre + s)) - .collect(Collectors.toList()); - } - - static public void addAutoCompletionListener(final AutoCompletionListener listener) { - if (!autocompletion_listeners.contains(listener)) - autocompletion_listeners.add(listener); - } - - static public void removeAutoCompletionListener(final AutoCompletionListener listener) { - autocompletion_listeners.remove(listener); - } - - @Override - public List getCompletionsImpl(final JTextComponent comp) { - final ArrayList completions = new ArrayList<>(); - String currentLine, - codeWithoutLastLine, - alreadyEnteredText = this.getAlreadyEnteredText(comp); - try { - codeWithoutLastLine = comp.getText(0, comp.getCaretPosition()); - final int lastLineBreak = codeWithoutLastLine.lastIndexOf("\n") + 1; - currentLine = codeWithoutLastLine.substring(lastLineBreak); // up to the caret position - codeWithoutLastLine = codeWithoutLastLine.substring(0, lastLineBreak); - } catch (BadLocationException e1) { - e1.printStackTrace(); - return completions; - } - // Completions provided by listeners (e.g. for methods and fields and variables and builtins from jython-autocompletion package) - for (final AutoCompletionListener listener: new Vector<>(autocompletion_listeners)) { - try { - final List cs = listener.completionsFor(this, codeWithoutLastLine, currentLine, alreadyEnteredText); - if (null != cs) - completions.addAll(cs); - } catch (Exception e) { - System.out.println("Failed to get autocompletions from " + listener); - e.printStackTrace(); - } - } - // Java class discovery for completions with auto-imports - completions.addAll(getCompletions(alreadyEnteredText)); - return completions; - } - - /** Completions to discover (autocomplete imports) and auto-import java classes. */ - public List getCompletions(final String text) { - // don't block - if (!ClassUtil.isCacheReady()) return Collections.emptyList(); - - // E.g. "from ij" to expand to a package name and class like ij or ij.gui or ij.plugin - final Matcher m1 = fromImport.matcher(text); - if (m1.find()) - return asCompletionList(ClassUtil.findClassNamesContaining(m1.group(3)).map(formatter::singleToImportStatement), ""); - - final Matcher m1f = fastImport.matcher(text); - if (m1f.find()) - return asCompletionList(ClassUtil.findClassNamesForPackage(m1f.group(2)).map(formatter::singleToImportStatement), ""); - - // E.g. "from ij.gui import Roi, Po" to expand to PolygonRoi, PointRoi for Jython - final Matcher m2 = importStatement.matcher(text); - if (m2.find()) { - String packageName = m2.group(3), - className = m2.group(4); // incomplete or empty, or multiple separated by commas with the last one incomplete or empty - - System.out.println("m2 matches className: " + className); - final String[] bycomma = className.split(","); - String precomma = ""; - if (bycomma.length > 1) { - className = bycomma[bycomma.length -1].trim(); // last one - for (int i=0; i stream; - if (className.length() > 0) - stream = ClassUtil.findClassNamesStartingWith(null == packageName ? className : packageName + "." + className); - else - stream = ClassUtil.findClassNamesForPackage(packageName); - // Simple class names - stream = stream.map((s) -> s.substring(Math.max(0, s.lastIndexOf('.') + 1))); - return asCompletionList(stream, m2.group(1) + precomma); - } - - final Matcher m3 = simpleClassName.matcher(text); - if (m3.find()) { - // Side effect: insert the import at the top of the file if necessary - //return asCompletionList(ClassUtil.findSimpleClassNamesStartingWith(m3.group(2)).stream(), m3.group(1)); - return ClassUtil.findSimpleClassNamesStartingWith(m3.group(2)).stream() - .map(className -> new ImportCompletionImpl(JythonAutocompletionProvider.this, - m3.group(1) + className.substring(className.lastIndexOf('.') + 1), - className, - formatter.singleToImportStatement(className))) - .collect(Collectors.toList()); - } - - - /* Covered by listener from jython-completions - - final Matcher m4 = staticMethodOrField.matcher(text); - if (m4.find()) { - try { - final String simpleClassName = m4.group(3), // expected complete, e.g. ImagePlus - methodOrFieldSeed = m4.group(4).toLowerCase(); // incomplete: e.g. "GR", a string to search for in the class declared fields or methods - - // Scan the script, parse the imports, find first one matching - final Import im = JythonAutoCompletion.findImportedClasses(text_area.getText()).get(simpleClassName); - if (null != im) { - final Class c = Class.forName(im.className); - final ArrayList matches = new ArrayList<>(); - for (final Field f: c.getFields()) { - if (Modifier.isStatic(f.getModifiers()) && f.getName().toLowerCase().startsWith(methodOrFieldSeed)) - matches.add(f.getName()); - } - for (final Method m: c.getMethods()) { - if (Modifier.isStatic(m.getModifiers()) && m.getName().toLowerCase().startsWith(methodOrFieldSeed)) - matches.add(m.getName() + "("); - } - return asCompletionList(matches.stream(), m4.group(1)); - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - */ - - return Collections.emptyList(); - } -} diff --git a/src/main/java/org/scijava/ui/swing/script/commands/ChooseFontSize.java b/src/main/java/org/scijava/ui/swing/script/commands/ChooseFontSize.java index 01235f43..4a704b1a 100644 --- a/src/main/java/org/scijava/ui/swing/script/commands/ChooseFontSize.java +++ b/src/main/java/org/scijava/ui/swing/script/commands/ChooseFontSize.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -54,7 +54,7 @@ public void run() { final float size = editor.getEditorPane().getFontSize(); changeFontSize(editor.getErrorScreen(), size); changeFontSize(editor.getTab().getScreenInstance(), size); - editor.updateTabAndFontSize(false); + editor.updateUI(false); } private void changeFontSize(final JTextArea a, final float size) { diff --git a/src/main/java/org/scijava/ui/swing/script/commands/ChooseTabSize.java b/src/main/java/org/scijava/ui/swing/script/commands/ChooseTabSize.java index c6ba7057..39fb389c 100644 --- a/src/main/java/org/scijava/ui/swing/script/commands/ChooseTabSize.java +++ b/src/main/java/org/scijava/ui/swing/script/commands/ChooseTabSize.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -54,7 +54,7 @@ public class ChooseTabSize extends DynamicCommand { @Override public void run() { editor.getEditorPane().setTabSize(tabSize); - editor.updateTabAndFontSize(false); + editor.updateUI(false); } protected void initializeChoice() { diff --git a/src/main/java/org/scijava/ui/swing/script/commands/GitGrep.java b/src/main/java/org/scijava/ui/swing/script/commands/GitGrep.java index d96d7b49..4b7fde6e 100644 --- a/src/main/java/org/scijava/ui/swing/script/commands/GitGrep.java +++ b/src/main/java/org/scijava/ui/swing/script/commands/GitGrep.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -37,7 +37,7 @@ import org.scijava.ui.swing.script.TextEditor; /** - * Calls git grep in a given directory. + * Calls {@code git grep} in a given directory. * * @author Johannes Schindelin */ diff --git a/src/main/java/org/scijava/ui/swing/script/commands/KillScript.java b/src/main/java/org/scijava/ui/swing/script/commands/KillScript.java index 9ee9b972..7b3c9593 100644 --- a/src/main/java/org/scijava/ui/swing/script/commands/KillScript.java +++ b/src/main/java/org/scijava/ui/swing/script/commands/KillScript.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/BeanshellHighlighter.java b/src/main/java/org/scijava/ui/swing/script/highliters/BeanshellHighlighter.java index ce77be48..9f1c8c74 100644 --- a/src/main/java/org/scijava/ui/swing/script/highliters/BeanshellHighlighter.java +++ b/src/main/java/org/scijava/ui/swing/script/highliters/BeanshellHighlighter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/ECMAScriptHighlighter.java b/src/main/java/org/scijava/ui/swing/script/highliters/ECMAScriptHighlighter.java index bb6d2f3e..5d00d907 100644 --- a/src/main/java/org/scijava/ui/swing/script/highliters/ECMAScriptHighlighter.java +++ b/src/main/java/org/scijava/ui/swing/script/highliters/ECMAScriptHighlighter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/IJ1MacroHighlighter.java b/src/main/java/org/scijava/ui/swing/script/highliters/IJ1MacroHighlighter.java index 6ad892ae..b404dfd8 100644 --- a/src/main/java/org/scijava/ui/swing/script/highliters/IJ1MacroHighlighter.java +++ b/src/main/java/org/scijava/ui/swing/script/highliters/IJ1MacroHighlighter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -33,12 +33,12 @@ import org.scijava.ui.swing.script.SyntaxHighlighter; /** - * SyntaxHighliter for ij1-macros. + * SyntaxHighliter for imagej-macros. * * @author Johannes Schindelin * @author Jonathan Hale */ -@Plugin(type = SyntaxHighlighter.class, name = "ij1-macro") +@Plugin(type = SyntaxHighlighter.class, name = "imagej-macro") public class IJ1MacroHighlighter extends ImageJMacroTokenMaker implements SyntaxHighlighter { diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/ImageJMacroTokenMaker.java b/src/main/java/org/scijava/ui/swing/script/highliters/ImageJMacroTokenMaker.java index 05b11701..a8699c7f 100644 --- a/src/main/java/org/scijava/ui/swing/script/highliters/ImageJMacroTokenMaker.java +++ b/src/main/java/org/scijava/ui/swing/script/highliters/ImageJMacroTokenMaker.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -2326,7 +2326,7 @@ private static int zzUnpackAttribute(final String packed, final int offset, /** * this buffer contains the current text to be matched and is the source of - * the yytext() string + * the {@code yytext()} string */ private char zzBuffer[]; @@ -2339,7 +2339,7 @@ private static int zzUnpackAttribute(final String packed, final int offset, /** the current text position in the buffer */ private int zzCurrentPos; - /** startRead marks the beginning of the yytext() string in the buffer */ + /** startRead marks the beginning of the {@code yytext()} string in the buffer */ private int zzStartRead; /** @@ -2491,7 +2491,7 @@ private boolean zzRefill() { * Resets the scanner to read from a new input stream. Does not close the old * reader. All internal variables are reset, the old input stream * cannot be reused (internal buffer is discarded and lost). Lexical - * state is set to YY_INITIAL. + * state is set to {@code YY_INITIAL}. * * @param reader the new input stream */ @@ -2582,8 +2582,8 @@ public final String yytext() { } /** - * Returns the character at position pos from the matched text. It is - * equivalent to yytext().charAt(pos), but faster + * Returns the character at position {@code pos} from the matched text. It is + * equivalent to {@code yytext().charAt(pos)}, but faster. * * @param pos the position of the character to fetch. A value from 0 to * yylength()-1. @@ -2635,6 +2635,11 @@ public void yypushback(final int number) { zzMarkedPos -= number; } + @Override + public int yystate() { + return zzLexicalState; // the current lexical state + } + /** * Resumes scanning until the next regular expression is matched, the end of * input is encountered or an I/O-Error occurs. diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/MatlabHighlighter.java b/src/main/java/org/scijava/ui/swing/script/highliters/MatlabHighlighter.java index a7c6e048..424e19a2 100644 --- a/src/main/java/org/scijava/ui/swing/script/highliters/MatlabHighlighter.java +++ b/src/main/java/org/scijava/ui/swing/script/highliters/MatlabHighlighter.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/MatlabTokenMaker.java b/src/main/java/org/scijava/ui/swing/script/highliters/MatlabTokenMaker.java index 80fab311..8555cf2f 100644 --- a/src/main/java/org/scijava/ui/swing/script/highliters/MatlabTokenMaker.java +++ b/src/main/java/org/scijava/ui/swing/script/highliters/MatlabTokenMaker.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -578,7 +578,7 @@ private boolean zzRefill() throws java.io.IOException { * Resets the scanner to read from a new input stream. Does not close the old * reader. All internal variables are reset, the old input stream * cannot be reused (internal buffer is discarded and lost). Lexical - * state is set to YY_INITIAL. + * state is set to {@code YY_INITIAL}. * * @param reader the new input stream */ @@ -678,15 +678,15 @@ public final void yybegin(final int newState) { } /** - * Returns the text matched by the current regular expression. + * @return the text matched by the current regular expression. */ public final String yytext() { return new String(zzBuffer, zzStartRead, zzMarkedPos - zzStartRead); } /** - * Returns the character at position pos from the matched text. It is - * equivalent to yytext().charAt(pos), but faster + * Returns the character at position {@code pos} from the matched text. It is + * equivalent to {@code yytext().charAt(pos)}, but faster. * * @param pos the position of the character to fetch. A value from 0 to * yylength()-1. @@ -697,7 +697,7 @@ public final char yycharat(final int pos) { } /** - * Returns the length of the matched text region. + * @return the length of the matched text region */ public final int yylength() { return zzMarkedPos - zzStartRead; diff --git a/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaLanguageSupportPlugin.java b/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaLanguageSupportPlugin.java index 27ed6349..ded26a54 100644 --- a/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaLanguageSupportPlugin.java +++ b/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaLanguageSupportPlugin.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaScriptLanguageSupportPlugin.java b/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaScriptLanguageSupportPlugin.java index d6500aaa..b0ad9547 100644 --- a/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaScriptLanguageSupportPlugin.java +++ b/src/main/java/org/scijava/ui/swing/script/languagesupport/JavaScriptLanguageSupportPlugin.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/languagesupport/JythonLanguageSupportPlugin.java b/src/main/java/org/scijava/ui/swing/script/languagesupport/JythonLanguageSupportPlugin.java deleted file mode 100644 index deecba2d..00000000 --- a/src/main/java/org/scijava/ui/swing/script/languagesupport/JythonLanguageSupportPlugin.java +++ /dev/null @@ -1,91 +0,0 @@ -/*- - * #%L - * Script Editor and Interpreter for SciJava script languages. - * %% - * Copyright (C) 2009 - 2020 SciJava developers. - * %% - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - * #L% - */ -package org.scijava.ui.swing.script.languagesupport; - -import org.fife.rsta.ac.AbstractLanguageSupport; -import org.fife.ui.autocomplete.AutoCompletion; -import org.fife.ui.autocomplete.CompletionProvider; -import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; -import org.scijava.Priority; -import org.scijava.plugin.Plugin; -import org.scijava.ui.swing.script.LanguageSupportPlugin; -import org.scijava.ui.swing.script.LanguageSupportService; -import org.scijava.ui.swing.script.autocompletion.JythonAutocompletionProvider; -import org.scijava.ui.swing.script.autocompletion.JythonImportFormat; -import org.scijava.ui.swing.script.autocompletion.JythonAutoCompletion; - -/** - * {@link LanguageSupportPlugin} for the jython language. - * - * @author Albert Cardona - * - * @see LanguageSupportService - */ -@Plugin(type = LanguageSupportPlugin.class, priority = Priority.HIGH) -public class JythonLanguageSupportPlugin extends AbstractLanguageSupport implements LanguageSupportPlugin -{ - - private AutoCompletion ac; - private RSyntaxTextArea text_area; - - public JythonLanguageSupportPlugin() { - setAutoCompleteEnabled(true); - setShowDescWindow(true); - } - - @Override - public String getLanguageName() { - return "python"; - } - - @Override - public void install(final RSyntaxTextArea textArea) { - this.text_area = textArea; - this.ac = this.createAutoCompletion(null); - this.ac.install(textArea); - // store upstream - super.installImpl(textArea, this.ac); - } - - @Override - public void uninstall(final RSyntaxTextArea textArea) { - if (textArea == this.text_area) { - super.uninstallImpl(textArea); // will call this.acp.uninstall(); - } - } - - /** - * Ignores the argument. - */ - @Override - protected AutoCompletion createAutoCompletion(CompletionProvider p) { - return new JythonAutoCompletion(new JythonAutocompletionProvider(text_area, new JythonImportFormat())); - } - -} diff --git a/src/main/java/org/scijava/ui/swing/script/search/ScriptSourceSearchActionFactory.java b/src/main/java/org/scijava/ui/swing/script/search/ScriptSourceSearchActionFactory.java new file mode 100644 index 00000000..af9cd763 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/search/ScriptSourceSearchActionFactory.java @@ -0,0 +1,131 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.search; + +import org.scijava.Context; +import org.scijava.log.LogService; +import org.scijava.module.ModuleInfo; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; +import org.scijava.script.ScriptInfo; +import org.scijava.script.ScriptService; +import org.scijava.search.DefaultSearchAction; +import org.scijava.search.SearchAction; +import org.scijava.search.SearchActionFactory; +import org.scijava.search.SearchResult; +import org.scijava.search.module.ModuleSearchResult; +import org.scijava.ui.swing.script.TextEditor; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; + +/** + * Search action for viewing the source code of a SciJava module. + * + * @author Curtis Rueden + */ +@Plugin(type = SearchActionFactory.class) +public class ScriptSourceSearchActionFactory implements SearchActionFactory { + + @Parameter private Context context; + + @Parameter private LogService log; + @Parameter private ScriptService scriptService; + + @Override public boolean supports(final SearchResult result) { + if (!(result instanceof ModuleSearchResult)) return false; + final ModuleInfo info = ((ModuleSearchResult) result).info(); + return info instanceof ScriptInfo; + } + + @Override public SearchAction create(final SearchResult result) { + ModuleInfo info = ((ModuleSearchResult) result).info(); + return new DefaultSearchAction("Source", // + () -> openScriptInTextEditor((ScriptInfo) info)); + } + + private void openScriptInTextEditor(final ScriptInfo script) { + final TextEditor editor = new TextEditor(context); + + final File scriptFile = getScriptFile(script); + if (scriptFile.exists()) { + // script is a file on disk; open it + editor.open(scriptFile); + editor.setVisible(true); + return; + } + + // try to read the script from its associated reader + final StringBuilder sb = new StringBuilder(); + try (final BufferedReader reader = script.getReader()) { + if (reader != null) { + // script is text from somewhere (a URL?); read it + while (true) { + final String line = reader.readLine(); + if (line == null) break; // eof + sb.append(line); + sb.append("\n"); + } + } + } + catch (final IOException exc) { + log.error("Error reading script: " + script.getPath(), exc); + } + + if (sb.length() > 0) { + // script came from somewhere, but not from a regular file + editor.getEditorPane().setFileName(scriptFile); + editor.getEditorPane().setText(sb.toString()); + } + else { + // give up, and report the problem + final String error = "[Cannot load script: " + script.getPath() + "]"; + editor.getEditorPane().setText(error); + } + + editor.setVisible(true); + } + + private File getScriptFile(final ScriptInfo script) { + final URL scriptURL = script.getURL(); + try { + if (scriptURL != null) return new File(scriptURL.toURI()); + } + catch (final URISyntaxException | IllegalArgumentException exc) { + log.debug(exc); + } + final File scriptDir = scriptService.getScriptDirectories().get(0); + return new File(scriptDir.getPath() + File.separator + script.getPath()); + } + +} diff --git a/src/test/java/org/scijava/script/parse/ParsingtonBindings.java b/src/test/java/org/scijava/script/parse/ParsingtonBindings.java index afbe50d4..e25496e5 100644 --- a/src/test/java/org/scijava/script/parse/ParsingtonBindings.java +++ b/src/test/java/org/scijava/script/parse/ParsingtonBindings.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/test/java/org/scijava/script/parse/ParsingtonScriptEngine.java b/src/test/java/org/scijava/script/parse/ParsingtonScriptEngine.java index e1e51147..1254ee15 100644 --- a/src/test/java/org/scijava/script/parse/ParsingtonScriptEngine.java +++ b/src/test/java/org/scijava/script/parse/ParsingtonScriptEngine.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -39,7 +39,7 @@ import javax.script.ScriptException; import org.scijava.parsington.Variable; -import org.scijava.parsington.eval.DefaultEvaluator; +import org.scijava.parsington.eval.DefaultTreeEvaluator; import org.scijava.parsington.eval.Evaluator; import org.scijava.script.AbstractScriptEngine; @@ -95,7 +95,7 @@ else if (args instanceof Collection) { public ParsingtonScriptEngine() { // NB: Create a default evaluator, but extended to support method calls. // This is a first-cut hack, just for fun -- fields are not supported yet. - this(new DefaultEvaluator() { + this(new DefaultTreeEvaluator() { @Override public Object dot(final Object a, final Object b) { diff --git a/src/test/java/org/scijava/script/parse/ParsingtonScriptLanguage.java b/src/test/java/org/scijava/script/parse/ParsingtonScriptLanguage.java index e636a33f..b8576549 100644 --- a/src/test/java/org/scijava/script/parse/ParsingtonScriptLanguage.java +++ b/src/test/java/org/scijava/script/parse/ParsingtonScriptLanguage.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonImportFormat.java b/src/test/java/org/scijava/script/search/ScriptSourceSearchActionFactoryTest.java similarity index 52% rename from src/main/java/org/scijava/ui/swing/script/autocompletion/JythonImportFormat.java rename to src/test/java/org/scijava/script/search/ScriptSourceSearchActionFactoryTest.java index 45cbba3b..adeae507 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/JythonImportFormat.java +++ b/src/test/java/org/scijava/script/search/ScriptSourceSearchActionFactoryTest.java @@ -1,8 +1,8 @@ -/*- +/* * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -26,20 +26,51 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.ui.swing.script.autocompletion; - -public class JythonImportFormat implements ImportFormat -{ - @Override - public final String singleToImportStatement(final String className) { - final int idot = className.lastIndexOf('.'); - if (-1 == idot) - return "import " + className; - return dualToImportStatement(className.substring(0, idot), className.substring(idot + 1)); + +package org.scijava.script.search; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.scijava.Context; +import org.scijava.script.ScriptInfo; +import org.scijava.search.SearchActionFactory; +import org.scijava.search.SearchResult; +import org.scijava.search.module.ModuleSearchResult; +import org.scijava.ui.swing.script.search.ScriptSourceSearchActionFactory; + +import java.io.StringReader; + +import static org.junit.Assert.assertTrue; + +/** + * Basic regression test for {@link ScriptSourceSearchActionFactory} + * + * @author Gabriel Selzer + */ +public class ScriptSourceSearchActionFactoryTest { + + private Context context; + private final SearchActionFactory factory = + new ScriptSourceSearchActionFactory(); + + // -- Test setup -- + + @Before public void setUp() { + context = new Context(); } - @Override - public String dualToImportStatement(final String packageName, final String simpleClassName) { - return "from " + packageName + " import " + simpleClassName; + @After public void tearDown() { + context.dispose(); } + + @Test public void testScriptSourceAction() + { + final String script = "\"Hello World!\"\n"; + final StringReader reader = new StringReader(script); + final ScriptInfo info = new ScriptInfo(context, "test.groovy", reader); + final SearchResult searchResult = new ModuleSearchResult(info, ""); + assertTrue(factory.supports(searchResult)); + } + } diff --git a/src/test/java/org/scijava/ui/swing/script/ScriptEditorTestDrive.java b/src/test/java/org/scijava/ui/swing/script/ScriptEditorTestDrive.java index a7c45b1d..75393d9f 100644 --- a/src/test/java/org/scijava/ui/swing/script/ScriptEditorTestDrive.java +++ b/src/test/java/org/scijava/ui/swing/script/ScriptEditorTestDrive.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -29,34 +29,18 @@ package org.scijava.ui.swing.script; -import java.awt.event.WindowAdapter; -import java.awt.event.WindowEvent; - -import javax.swing.SwingUtilities; - -import org.scijava.Context; -import org.scijava.script.ScriptLanguage; -import org.scijava.script.ScriptService; -import org.scijava.ui.swing.script.TextEditor; - /** * Interactive test for the script editor. + *

+ * This class exists so that you can launch the Script Editor conveniently from + * test scope, with the Groovy language included on the classpath. + *

* * @author Johannes Schindelin + * @author Curtis Rueden */ public class ScriptEditorTestDrive { public static void main(String[] args) throws Exception { - final Context context = new Context(); - final TextEditor editor = new TextEditor(context); - final ScriptService scriptService = context.getService(ScriptService.class); - final ScriptLanguage lang = scriptService.getLanguageByName("Groovy"); - editor.setLanguage(lang); - editor.addWindowListener(new WindowAdapter() { - @Override - public void windowClosed(final WindowEvent e) { - SwingUtilities.invokeLater(() -> context.dispose()); - } - }); - editor.setVisible(true); + Main.launch("Groovy"); } } diff --git a/src/test/java/org/scijava/ui/swing/script/ScriptInterpreterTestDrive.java b/src/test/java/org/scijava/ui/swing/script/ScriptInterpreterTestDrive.java index 2ec53d76..4c558f1f 100644 --- a/src/test/java/org/scijava/ui/swing/script/ScriptInterpreterTestDrive.java +++ b/src/test/java/org/scijava/ui/swing/script/ScriptInterpreterTestDrive.java @@ -2,7 +2,7 @@ * #%L * Script Editor and Interpreter for SciJava script languages. * %% - * Copyright (C) 2009 - 2020 SciJava developers. + * Copyright (C) 2009 - 2025 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: