diff --git a/.gitignore b/.gitignore index f8e132dab..277d016d3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target/ # vim # *.swp + +# Eclipse annotation processing +.factorypath diff --git a/README.md b/README.md index 105f42119..68acedcc3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://github.com/scijava/incubator/actions/workflows/build.yml/badge.svg)](https://github.com/scijava/incubator/actions/workflows/build.yml) +[![Build Status](https://github.com/scijava/scijava/actions/workflows/build.yml/badge.svg)](https://github.com/scijava/scijava/actions/workflows/build.yml) # SciJava core diff --git a/awt/pom.xml b/awt/pom.xml new file mode 100644 index 000000000..37ed98951 --- /dev/null +++ b/awt/pom.xml @@ -0,0 +1,249 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-awt + 1.0.0-SNAPSHOT + + SciJava AWT + AWT widgets and platform plumbing, with no Swing anywhere. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.awt + + bsd_2 + SciJava developers. + + + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-command3 + ${scijava-command3.version} + + + org.scijava + scijava-context + ${scijava-context.version} + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-index + ${scijava-index.version} + + + org.scijava + scijava-ui3 + ${scijava-ui3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-harvest + ${scijava-harvest.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.slf4j + slf4j-api + + + + + + org.scijava + scijava-script3 + ${scijava-script3.version} + test + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + + + org.scijava + scijava-ui3-test + ${scijava-ui3-test.version} + test + + + + org.slf4j + slf4j-simple + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/awt/src/main/java/module-info.java b/awt/src/main/java/module-info.java new file mode 100644 index 000000000..7a55ce259 --- /dev/null +++ b/awt/src/main/java/module-info.java @@ -0,0 +1,46 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.awt { + + exports org.scijava.awt; + + // NB: one opens, to the container alone, so that it can inject and + // construct these plugins. `opens` is not `exports`. + opens org.scijava.awt to org.scijava.context; + + requires transitive java.desktop; + requires transitive org.scijava.ui3; + requires transitive org.scijava.command3; + requires transitive org.scijava.context; + requires org.scijava.discovery; + requires org.scijava.priority; + requires org.slf4j; + +} diff --git a/awt/src/main/java/org/scijava/awt/AwtChoice.java b/awt/src/main/java/org/scijava/awt/AwtChoice.java new file mode 100644 index 000000000..a35459833 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtChoice.java @@ -0,0 +1,126 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Choice; +import java.awt.Component; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; + +/** + * A widget for a parameter with a known set of values, declared or computed. + *

+ * NB: {@link Choice} holds strings and nothing else, so the widget keeps the + * values beside it and maps by position. A toolkit with no model layer is + * where a contract that assumed one would show. + *

+ * + * @author Curtis Rueden + */ +public class AwtChoice extends AwtWidget { + + private final Choice choice = new Choice(); + private final List values = new ArrayList<>(); + + public AwtChoice(final ParameterNode node, final ParameterModel model) { + super(node, model); + refresh(); + choice.addItemListener(e -> { + final int index = choice.getSelectedIndex(); + if (index >= 0 && index < values.size()) update(values.get(index)); + }); + } + + @Override + public Component component() { + return choice; + } + + @Override + protected void doRefresh() { + final List choices = node().choices(); + if (!choices.equals(values)) { + choice.removeAll(); + values.clear(); + for (final Object item : choices) { + values.add(item); + choice.add(String.valueOf(item)); + } + } + final Object value = value(); + final int index = indexOf(value); + if (index >= 0 && choice.getSelectedIndex() != index) { + choice.select(index); + } + } + + // -- Helper methods -- + + /** Finds the position a value stands at, declared choices being strings. */ + private int indexOf(final Object value) { + for (int i = 0; i < values.size(); i++) { + if (Objects.equals(values.get(i), value)) return i; + } + for (int i = 0; i < values.size(); i++) { + if (String.valueOf(values.get(i)).equals(String.valueOf(value))) return i; + } + return -1; + } + + /** Makes {@link AwtChoice}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements AwtWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return !node.choices().isEmpty(); + } + + @Override + public AwtWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new AwtChoice(node, model); + } + + @Override + public double priority() { + return Priority.HIGH; + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtDialog.java b/awt/src/main/java/org/scijava/awt/AwtDialog.java new file mode 100644 index 000000000..a9d012b12 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtDialog.java @@ -0,0 +1,185 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.BorderLayout; +import java.awt.Button; +import java.awt.Color; +import java.awt.Dialog; +import java.awt.Frame; +import java.awt.Label; +import java.awt.Panel; +import java.awt.Window; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.List; +import java.util.Map; + +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanels; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The dialog a user fills in, in AWT. + * + * @author Curtis Rueden + */ +public class AwtDialog { + + private static final Logger log = LoggerFactory.getLogger(AwtDialog.class); + + private final ParameterModel model; + private final WidgetPanels builder; + private final Dialog dialog; + private final Panel content = new Panel(new BorderLayout()); + private final Button okButton = new Button("OK"); + private final Label problem = new Label(); + + private AwtPanel panel; + private boolean accepted; + private boolean rebuilding; + private boolean placed; + + public AwtDialog(final Frame owner, final String title, + final ParameterModel model, final List> factories) + { + this.model = model; + builder = new WidgetPanels<>(factories, new AwtPanel.Factory(model)); + + // NB: AWT dialogs need a frame, even a throwaway one. + dialog = new Dialog(owner == null ? new Frame() : owner, title, true); + + problem.setForeground(Color.RED.darker()); + okButton.addActionListener(e -> { + accepted = true; + close(); + }); + final Button cancelButton = new Button("Cancel"); + cancelButton.addActionListener(e -> close()); + dialog.addWindowListener(new WindowAdapter() { + + @Override + public void windowClosing(final WindowEvent e) { + close(); + } + }); + + final Panel buttons = new Panel(); + buttons.add(okButton); + buttons.add(cancelButton); + final Panel south = new Panel(new BorderLayout()); + south.add(problem, BorderLayout.CENTER); + south.add(buttons, BorderLayout.EAST); + + dialog.setLayout(new BorderLayout()); + dialog.add(content, BorderLayout.CENTER); + dialog.add(south, BorderLayout.SOUTH); + + model.onTreeChanged(tree -> Edt.later(() -> { + log.debug("Parameter tree changed; rebuilding widgets"); + rebuild(); + })); + model.values().onChange(change -> Edt.later(this::refresh)); + + rebuild(); + } + + /** + * Shows the dialog and waits for the user. + * + * @return true if the user accepted, false if they dismissed it + */ + public boolean showDialog() { + dialog.setVisible(true); // NB: modal, so this blocks until disposed + return accepted; + } + + /** Gets the window itself, to position or decorate it. */ + public Window window() { + return dialog; + } + + // -- Helper methods -- + + private void close() { + dialog.setVisible(false); + dialog.dispose(); + } + + private void rebuild() { + if (rebuilding) return; + rebuilding = true; + try { + panel = (AwtPanel) builder.build(model); + log.debug("Built {} widget(s) for {}", panel.widgets().size(), dialog + .getTitle()); + if (panel.widgets().isEmpty()) { + log.warn("No widgets for {}: either it declares no inputs, or no " + + "widget factory accepted them", dialog.getTitle()); + } + content.removeAll(); + content.add(panel.component(), BorderLayout.NORTH); + validate(); + // NB: as in the other bindings - sized once, may grow, never moves + // and never shrinks. + final int width = dialog.getWidth(); + final int height = dialog.getHeight(); + dialog.pack(); + if (placed) { + dialog.setSize(Math.max(width, dialog.getWidth()), Math.max(height, + dialog.getHeight())); + } + else { + dialog.setLocationRelativeTo(dialog.getOwner()); + placed = true; + } + } + finally { + rebuilding = false; + } + } + + private void refresh() { + if (rebuilding || panel == null) return; + panel.refresh(); + validate(); + } + + /** Shows what is wrong, and lets the user proceed only when nothing is. */ + private void validate() { + final Map problems = model.problems(); + panel.showProblems(problems); + okButton.setEnabled(problems.isEmpty()); + problem.setText(problems.isEmpty() ? "" : problems.values().iterator() + .next()); + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtFile.java b/awt/src/main/java/org/scijava/awt/AwtFile.java new file mode 100644 index 000000000..d03393341 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtFile.java @@ -0,0 +1,131 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.BorderLayout; +import java.awt.Button; +import java.awt.Component; +import java.awt.Dialog; +import java.awt.FileDialog; +import java.awt.Frame; +import java.awt.Panel; +import java.awt.TextField; +import java.awt.Window; +import java.io.File; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for a file: a path to type, and a button to browse. + *

+ * NB: {@link FileDialog} is the platform's own dialog, which is a point in + * AWT's favor - and it cannot choose a directory on every platform, which is a + * point against. A directory parameter therefore falls back to typing the + * path, rather than pretending. + *

+ * + * @author Curtis Rueden + */ +public class AwtFile extends AwtWidget { + + private final TextField path = new TextField(16); + private final Panel panel = new Panel(new BorderLayout(4, 0)); + + public AwtFile(final ParameterNode node, final ParameterModel model) { + super(node, model); + final Button browse = new Button("Browse..."); + browse.addActionListener(e -> browse()); + panel.add(path, BorderLayout.CENTER); + panel.add(browse, BorderLayout.EAST); + refresh(); + path.addTextListener(e -> { + final String text = path.getText(); + update(text.isEmpty() ? null : new File(text)); + }); + } + + @Override + public Component component() { + return panel; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final String text = value == null ? "" : value.toString(); + if (!text.equals(path.getText())) path.setText(text); + } + + // -- Helper methods -- + + private void browse() { + final FileDialog dialog = new FileDialog(frame(), "Choose a file", // + Widgets.isStyle(node(), "save") ? FileDialog.SAVE : FileDialog.LOAD); + final String text = path.getText(); + if (!text.isEmpty()) dialog.setFile(text); + dialog.setVisible(true); + if (dialog.getFile() == null) return; + path.setText(new File(dialog.getDirectory(), dialog.getFile()).getPath()); + } + + /** Finds a frame to own the file dialog, AWT requiring one. */ + private Frame frame() { + for (Component c = panel; c != null; c = c.getParent()) { + if (c instanceof Frame) return (Frame) c; + if (c instanceof Dialog) { + final Window owner = ((Dialog) c).getOwner(); + if (owner instanceof Frame) return (Frame) owner; + } + } + return null; + } + + /** Makes {@link AwtFile}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements AwtWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return node.choices().isEmpty() && Widgets.type(node) == File.class; + } + + @Override + public AwtWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new AwtFile(node, model); + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtInputHarvester.java b/awt/src/main/java/org/scijava/awt/AwtInputHarvester.java new file mode 100644 index 000000000..f39621bb3 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtInputHarvester.java @@ -0,0 +1,121 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.util.ArrayList; +import java.util.List; + +import org.scijava.context.Context; +import org.scijava.context.Dependency; +import org.scijava.context.Plugin; +import org.scijava.discovery.Discovery; +import org.scijava.execute.Preprocessor; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.InputHarvester; +import org.scijava.ui3.WidgetFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Asks the user for a run's remaining inputs, in an AWT dialog. + * + * @author Curtis Rueden + */ +@Plugin(type = Preprocessor.class) +public class AwtInputHarvester extends InputHarvester { + + private static final Logger log = LoggerFactory.getLogger( + AwtInputHarvester.class); + + @Dependency(required = false) + private Context context; + + private List> factories; + + private String title = "Inputs"; + + /** Creates a harvester whose widgets come from the container. */ + public AwtInputHarvester() { + // NB: the container injects the context and discovers the widgets. + } + + /** Creates a harvester with the given widgets, and no container. */ + public AwtInputHarvester(final List> factories) { + this.factories = List.copyOf(factories); + } + + /** Creates a harvester whose widgets come from the given container. */ + public static AwtInputHarvester of(final Context context) { + final AwtInputHarvester harvester = new AwtInputHarvester(); + harvester.context = context; + return harvester; + } + + /** Sets the title the dialog shows. */ + public void setTitle(final String title) { + this.title = title; + } + + @Override + public boolean harvest(final ParameterModel model) { + // NB: two hops, as in Swing: a modal AWT dialog blocks the thread that + // shows it, so it must be built in one visit and shown in another. + final AwtDialog dialog = Edt.get(() -> new AwtDialog(null, title, model, + factories())); + return Edt.get(dialog::showDialog); + } + + // -- Helper methods -- + + /** Gets the widgets available, discovering them on first use. */ + private synchronized List> factories() { + if (factories != null) return factories; + final List> found = new ArrayList<>(); + if (context != null) { + for (final Discovery discovery : context.plugins( + WidgetFactory.class)) + { + final WidgetFactory factory = discovery.get(); + // NB: keeps this dialog free of another toolkit's widgets, even + // where both are on the classpath. + if (!AwtWidget.class.isAssignableFrom(factory.widgetType())) continue; + @SuppressWarnings("unchecked") + final WidgetFactory awt = (WidgetFactory) factory; + found.add(awt); + } + } + if (found.isEmpty()) { + log.warn("No AWT widget factories discovered. Is the annotation index " + + "(META-INF/json/org.scijava.context.Plugin) on the classpath?"); + } + factories = List.copyOf(found); + return factories; + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtMenus.java b/awt/src/main/java/org/scijava/awt/AwtMenus.java new file mode 100644 index 000000000..9ba49ad8a --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtMenus.java @@ -0,0 +1,118 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Menu; +import java.awt.MenuBar; +import java.awt.MenuItem; +import java.awt.MenuShortcut; +import java.awt.event.KeyEvent; +import java.util.Optional; +import java.util.function.Consumer; + +import org.scijava.command3.Accelerator; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuCreator; +import org.scijava.command3.MenuTree; +import org.scijava.command3.Menus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Builds an AWT menu bar from a {@link MenuTree}. + * + * @author Curtis Rueden + */ +public class AwtMenus implements MenuCreator { + + private static final Logger log = LoggerFactory.getLogger(AwtMenus.class); + + private final Consumer onSelect; + + public AwtMenus(final Consumer onSelect) { + this.onSelect = onSelect; + } + + /** Builds a menu bar holding the given menu tree. */ + public static MenuBar create(final MenuTree root, + final Consumer onSelect) + { + return Menus.build(root, new MenuBar(), new AwtMenus(onSelect)); + } + + @Override + public Menu topMenu(final MenuTree node, final MenuBar bar) { + final Menu menu = new Menu(node.label()); + bar.add(menu); + return menu; + } + + @Override + public Menu subMenu(final MenuTree node, final Menu parent) { + final Menu menu = new Menu(node.label()); + parent.add(menu); + return menu; + } + + @Override + public void item(final MenuTree leaf, final Menu parent) { + final CommandInfo command = leaf.entry().orElseThrow(); + final MenuItem item = new MenuItem(leaf.label()); + shortcut(command.accelerator().orElse(null)).ifPresent(item::setShortcut); + // NB: AWT menu items have no icon at all, so iconPath is ignored here. + item.addActionListener(e -> onSelect.accept(command)); + parent.add(item); + } + + // -- Helper methods -- + + /** + * Turns an accelerator into an AWT menu shortcut. + *

+ * NB: {@link MenuShortcut} can express the platform's menu shortcut and + * shift, and nothing else - no alt, and no shortcut without the menu + * modifier. An accelerator AWT cannot say is dropped rather than + * approximated into a different key combination. + *

+ */ + static Optional shortcut(final String accelerator) { + final Optional parsed = Accelerator.parse(accelerator); + if (parsed.isEmpty()) return Optional.empty(); + final Accelerator a = parsed.get(); + if (!a.isShortcut() || a.isAlt() || !a.isCharacter()) { + if (a.hasModifiers()) { + log.debug("AWT cannot express the accelerator {}", accelerator); + } + return Optional.empty(); + } + final int key = KeyEvent.getExtendedKeyCodeForChar(a.key().charAt(0)); + return Optional.of(new MenuShortcut(key, a.isShift())); + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtMessage.java b/awt/src/main/java/org/scijava/awt/AwtMessage.java new file mode 100644 index 000000000..e53faf8ff --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtMessage.java @@ -0,0 +1,94 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Component; +import java.awt.Label; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget that shows text rather than collecting it. + * + * @author Curtis Rueden + */ +public class AwtMessage extends AwtWidget { + + private final Label label = new Label(); + + public AwtMessage(final ParameterNode node, final ParameterModel model) { + super(node, model); + refresh(); + } + + @Override + public Component component() { + return label; + } + + @Override + public boolean isLabeled() { + return false; // NB: the message is the whole row. + } + + @Override + protected void doRefresh() { + final Object value = value(); + label.setText(value == null ? "" : value.toString()); + } + + /** Makes {@link AwtMessage}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements AwtWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return Widgets.isStyle(node, "message"); + } + + @Override + public AwtWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new AwtMessage(node, model); + } + + @Override + public double priority() { + return Priority.VERY_HIGH; + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtNumber.java b/awt/src/main/java/org/scijava/awt/AwtNumber.java new file mode 100644 index 000000000..aba2c707e --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtNumber.java @@ -0,0 +1,193 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Panel; +import java.awt.Scrollbar; +import java.awt.TextField; +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for a number: a text field, optionally beside a scroll bar. + *

+ * NB: AWT has no spinner and no slider, only {@link Scrollbar}. This is the + * harshest test of the contracts so far - the widget must honor {@code min}, + * {@code max}, {@code stepSize} and the soft bounds with nothing but a text + * field and integer scroll positions - and none of it needed the model to + * change. A scale in steps, rather than in the parameter's own units, is what + * lets a scroll bar drive a {@code double}. + *

+ * + * @author Curtis Rueden + */ +public class AwtNumber extends AwtWidget { + + private final Class type; + private final Number min; + private final Number max; + private final Number softMin; + private final Number softMax; + private final Number step; + + private final TextField field = new TextField(8); + private final Scrollbar scrollBar; + private final Panel panel = new Panel(new BorderLayout(4, 0)); + + private boolean syncing; + + public AwtNumber(final ParameterNode node, final ParameterModel model) { + super(node, model); + type = Widgets.box(Widgets.type(node)); + min = Widgets.min(node); + max = Widgets.max(node); + softMin = Widgets.softMin(node); + softMax = Widgets.softMax(node); + step = Widgets.stepSize(node); + + final boolean scalable = softMin != null && softMax != null && // + (Widgets.isStyle(node, "slider") || Widgets.isStyle(node, "scroll bar")); + scrollBar = scalable ? new Scrollbar(Scrollbar.HORIZONTAL, 0, 1, 0, steps() + + 1) : null; + if (scrollBar != null) panel.add(scrollBar, BorderLayout.CENTER); + panel.add(field, scrollBar == null ? BorderLayout.CENTER + : BorderLayout.EAST); + + refresh(); + field.addTextListener(e -> typed()); + if (scrollBar != null) { + scrollBar.addAdjustmentListener(e -> { + if (syncing) return; + final Number value = number(fromScale(scrollBar.getValue())); + field.setText(String.valueOf(value)); + update(value); + }); + } + } + + @Override + public Component component() { + return panel; + } + + @Override + protected void doRefresh() { + final Number value = number(value()); + final String text = String.valueOf(value); + if (!text.equals(field.getText())) field.setText(text); + syncScale(value); + } + + // -- Helper methods -- + + private void typed() { + final Number typed = Widgets.toType(field.getText(), type); + // NB: half-typed text is not a value: "-" and "" are how a number is + // entered, not a reason to write a zero into the parameter. + if (typed == null) return; + final Number clamped = clamp(typed); + syncScale(clamped); + update(clamped); + } + + private Number clamp(final Number value) { + if (min != null && value.doubleValue() < min.doubleValue()) return min; + if (max != null && value.doubleValue() > max.doubleValue()) return max; + return value; + } + + /** Gets how many steps the scale spans, capped at a usable number. */ + private int steps() { + final double span = softMax.doubleValue() - softMin.doubleValue(); + return (int) Math.max(1, Math.min(span / step.doubleValue(), 10_000)); + } + + private void syncScale(final Number value) { + if (scrollBar == null) return; + syncing = true; + try { + final double span = softMax.doubleValue() - softMin.doubleValue(); + final int pos = (int) Math.round((value.doubleValue() - softMin + .doubleValue()) / span * steps()); + scrollBar.setValue(Math.max(0, Math.min(steps(), pos))); + } + finally { + syncing = false; + } + } + + private double fromScale(final int pos) { + final double span = softMax.doubleValue() - softMin.doubleValue(); + return softMin.doubleValue() + span * pos / steps(); + } + + /** Reads a value as the parameter's own numeric type. */ + private Number number(final Object value) { + if (value == null) return min != null ? number(min) : (Number) Widgets + .toType("0", type); + final Number n = value instanceof Number ? (Number) value // + : Widgets.toType(String.valueOf(value), type); + if (n == null) return (Number) Widgets.toType("0", type); + if (type == Byte.class) return n.byteValue(); + if (type == Short.class) return n.shortValue(); + if (type == Integer.class) return n.intValue(); + if (type == Long.class) return n.longValue(); + if (type == Float.class) return n.floatValue(); + return n.doubleValue(); + } + + /** Makes {@link AwtNumber}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements AwtWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + if (!node.choices().isEmpty() || !Widgets.isNumber(node)) return false; + final Class type = Widgets.box(Widgets.type(node)); + return type != BigInteger.class && type != BigDecimal.class; + } + + @Override + public AwtWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new AwtNumber(node, model); + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtPanel.java b/awt/src/main/java/org/scijava/awt/AwtPanel.java new file mode 100644 index 000000000..d41bb5b5f --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtPanel.java @@ -0,0 +1,193 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Button; +import java.awt.Color; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.Label; +import java.awt.Panel; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetPanel; +import org.scijava.ui3.WidgetPanelFactory; + +/** + * A panel of AWT widgets: the body of a dialog, or one group within it. + *

+ * NB: AWT has no titled border and no collapsible pane, so a group is a label + * above its contents and a collapsible one is a button that hides them. Doing + * it by hand is the price of the toolkit; that the contract asks for + * nothing more than "show a group, let it be folded" is the finding. + *

+ * + * @author Curtis Rueden + */ +public class AwtPanel extends AwtWidget implements WidgetPanel { + + private final List widgets; + private final Map labels = new LinkedHashMap<>(); + private final Panel body = new Panel(new GridBagLayout()); + private final Panel panel = new Panel(new GridBagLayout()); + + private boolean collapsed; + + public AwtPanel(final ParameterNode group, final List widgets, + final ParameterModel model) + { + super(group, model); + this.widgets = new ArrayList<>(widgets); + layOut(); + + final GridBagConstraints c = new GridBagConstraints(); + c.gridx = 0; + c.fill = GridBagConstraints.HORIZONTAL; + c.weightx = 1; + c.anchor = GridBagConstraints.LINE_START; + if (group == null) { + c.gridy = 0; + panel.add(body, c); + return; + } + if (group.isCollapsible()) { + collapsed = group.isCollapsed(); + final Button header = new Button(title(group)); + header.addActionListener(e -> { + collapsed = !collapsed; + body.setVisible(!collapsed); + header.setLabel(title(group)); + panel.invalidate(); + panel.validate(); + }); + c.gridy = 0; + panel.add(header, c); + body.setVisible(!collapsed); + } + else { + c.gridy = 0; + panel.add(new Label(group.label()), c); + } + c.gridy = 1; + c.insets = new Insets(0, 12, 0, 0); // NB: indent, for want of a border + panel.add(body, c); + } + + @Override + public List widgets() { + return List.copyOf(widgets); + } + + @Override + public Component component() { + return panel; + } + + @Override + public boolean isLabeled() { + return false; // NB: a group carries its own title. + } + + @Override + public void showProblems(final Map problems) { + labels.forEach((key, label) -> { + final String problem = problems.get(key); + label.setForeground(problem == null ? Color.BLACK : Color.RED.darker()); + }); + WidgetPanel.super.showProblems(problems); + } + + @Override + protected void doRefresh() { + widgets.forEach(AwtWidget::refresh); + } + + // -- Helper methods -- + + private String title(final ParameterNode group) { + return (collapsed ? "▶ " : "▼ ") + group.label(); + } + + private void layOut() { + final GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 4, 2, 4); + int row = 0; + for (final AwtWidget widget : widgets) { + c.gridy = row++; + c.fill = GridBagConstraints.HORIZONTAL; + if (widget.isLabeled()) { + final ParameterNode node = widget.node(); + final Label label = new Label(node.label(), Label.RIGHT); + node.member().ifPresent(m -> labels.put(m.member().key(), label)); + c.gridx = 0; + c.weightx = 0; + c.gridwidth = 1; + c.anchor = GridBagConstraints.LINE_END; + body.add(label, c); + c.gridx = 1; + c.weightx = 1; + c.anchor = GridBagConstraints.LINE_START; + body.add(widget.component(), c); + } + else { + c.gridx = 0; + c.weightx = 1; + c.gridwidth = 2; + c.anchor = GridBagConstraints.LINE_START; + body.add(widget.component(), c); + c.gridwidth = 1; + } + } + } + + /** Makes {@link AwtPanel}s. */ + public static class Factory implements WidgetPanelFactory { + + private final ParameterModel model; + + public Factory(final ParameterModel model) { + this.model = model; + } + + @Override + public AwtWidget create(final ParameterNode group, + final List widgets) + { + return new AwtPanel(group, widgets, model); + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtText.java b/awt/src/main/java/org/scijava/awt/AwtText.java new file mode 100644 index 000000000..a142113dc --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtText.java @@ -0,0 +1,124 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Component; +import java.awt.TextArea; +import java.awt.TextComponent; +import java.awt.TextField; +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for text: a field, an echo-masked field, or a multi-line area. + * + * @author Curtis Rueden + */ +public class AwtText extends AwtWidget { + + private final TextComponent text; + + public AwtText(final ParameterNode node, final ParameterModel model) { + super(node, model); + final int columns = Integer.parseInt(Widgets.styleValue(node, "columns") + .orElse("16")); + if (Widgets.isStyle(node, "text area")) { + text = new TextArea("", 4, columns, TextArea.SCROLLBARS_VERTICAL_ONLY); + } + else { + final TextField field = new TextField("", columns); + // NB: AWT has no password field, only an echo character - which is + // what a password field is, underneath. + if (Widgets.isStyle(node, "password")) field.setEchoChar('*'); + text = field; + } + refresh(); + text.addTextListener(e -> changed(text.getText())); + } + + @Override + public Component component() { + return text; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final String s = value == null ? "" : value.toString(); + if (!s.equals(text.getText())) text.setText(s); + } + + // -- Helper methods -- + + private void changed(final String s) { + final Class type = Widgets.box(Widgets.type(node())); + if (type == String.class) update(s); + else if (type == Character.class) { + if (!s.isEmpty()) update(s.charAt(0)); + } + else { + final Number value = Widgets.toType(s, type); + if (value != null) update(value); + } + } + + /** Makes {@link AwtText}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements AwtWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + if (!node.choices().isEmpty()) return false; + final Class type = Widgets.box(Widgets.type(node)); + return Widgets.isText(node) || type == BigInteger.class || + type == BigDecimal.class; + } + + @Override + public AwtWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new AwtText(node, model); + } + + @Override + public double priority() { + return Priority.LOW; + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtToggle.java b/awt/src/main/java/org/scijava/awt/AwtToggle.java new file mode 100644 index 000000000..d57c638c4 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtToggle.java @@ -0,0 +1,85 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Checkbox; +import java.awt.Component; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for a true/false value. + * + * @author Curtis Rueden + */ +public class AwtToggle extends AwtWidget { + + private final Checkbox checkBox = new Checkbox(); + + public AwtToggle(final ParameterNode node, final ParameterModel model) { + super(node, model); + refresh(); + checkBox.addItemListener(e -> update(checkBox.getState())); + } + + @Override + public Component component() { + return checkBox; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final boolean selected = value instanceof Boolean && (Boolean) value; + if (checkBox.getState() != selected) checkBox.setState(selected); + } + + /** Makes {@link AwtToggle}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements AwtWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return Widgets.isBoolean(node); + } + + @Override + public AwtWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new AwtToggle(node, model); + } + } +} diff --git a/awt/src/main/java/org/scijava/awt/AwtWidget.java b/awt/src/main/java/org/scijava/awt/AwtWidget.java new file mode 100644 index 000000000..0aadca074 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtWidget.java @@ -0,0 +1,62 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.Component; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.AbstractWidget; +import org.scijava.ui3.WidgetFactory; + +/** + * A {@link org.scijava.ui3.Widget} made of AWT components. + *

+ * NB: deliberately not a supertype of the Swing binding's widget, + * although {@code JComponent} is a {@code Component} and the hierarchy would + * allow it. Discovery filters factories by + * {@link WidgetFactory#widgetType()}, so making one a subtype of the other + * would mean a pure-AWT application silently filling its dialogs with Swing + * controls the moment scijava-swing appeared on the classpath - which is + * exactly what a pure-AWT application is trying to avoid. Sibling types keep + * that an explicit choice. + *

+ * + * @author Curtis Rueden + */ +public abstract class AwtWidget extends AbstractWidget { + + protected AwtWidget(final ParameterNode node, final ParameterModel model) { + super(node, model); + } + + /** Gets the component to place in the dialog. */ + public abstract Component component(); +} diff --git a/awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java b/awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java new file mode 100644 index 000000000..84ecc8329 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java @@ -0,0 +1,45 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import org.scijava.ui3.WidgetFactory; + +/** + * A {@link WidgetFactory} that makes AWT widgets. + * + * @author Curtis Rueden + */ +public interface AwtWidgetFactory extends WidgetFactory { + + @Override + default Class widgetType() { + return AwtWidget.class; + } +} diff --git a/awt/src/main/java/org/scijava/awt/Edt.java b/awt/src/main/java/org/scijava/awt/Edt.java new file mode 100644 index 000000000..42b885872 --- /dev/null +++ b/awt/src/main/java/org/scijava/awt/Edt.java @@ -0,0 +1,96 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.EventQueue; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +/** + * Getting on and off the event dispatch thread. + *

+ * NB: {@link EventQueue} rather than {@code SwingUtilities}, which merely + * forwards to it. The dispatch thread belongs to AWT, so code that only needs + * to reach it - a harvester, a status bar, a progress report - need not drag + * in Swing to do so. This is what the Swing binding shares with the AWT one, + * and it is the only kind of thing they share: no widget is common + * between them, {@link java.awt.Choice} and {@code JComboBox} having nothing + * to do with one another. + *

+ * + * @author Curtis Rueden + */ +public final class Edt { + + private Edt() { + // prevent instantiation of utility class + } + + /** Runs the given work on the dispatch thread, and waits for it. */ + public static void runAndWait(final Runnable work) { + get(() -> { + work.run(); + return null; + }); + } + + /** + * Runs the given work on the dispatch thread and returns what it produced. + * + * @param what the work produces + * @param work what to do on the dispatch thread + * @return its result + * @throws IllegalStateException if the work threw, or the wait was + * interrupted + */ + public static T get(final Supplier work) { + if (EventQueue.isDispatchThread()) return work.get(); + final AtomicReference result = new AtomicReference<>(); + try { + EventQueue.invokeAndWait(() -> result.set(work.get())); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting for the EDT", exc); + } + catch (final InvocationTargetException exc) { + final Throwable cause = exc.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + throw new IllegalStateException(cause); + } + return result.get(); + } + + /** Runs the given work on the dispatch thread, without waiting. */ + public static void later(final Runnable work) { + EventQueue.invokeLater(work); + } +} diff --git a/awt/src/test/java/org/scijava/awt/AwtMenusTest.java b/awt/src/test/java/org/scijava/awt/AwtMenusTest.java new file mode 100644 index 000000000..105e58bda --- /dev/null +++ b/awt/src/test/java/org/scijava/awt/AwtMenusTest.java @@ -0,0 +1,126 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.GraphicsEnvironment; +import java.awt.Menu; +import java.awt.MenuBar; +import java.awt.MenuItem; +import java.awt.MenuShortcut; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.command3.MenuTree; +import org.scijava.ui3.test.MenuConformance; + +/** + * Runs the shared menu conformance suite against the AWT menus. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class AwtMenusTest extends MenuConformance { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + @Override + protected MenuBar menuBar(final MenuTree root) { + return AwtMenus.create(root, command -> {}); + } + + @Override + protected List topMenus(final MenuBar bar) { + final List labels = new ArrayList<>(); + for (int i = 0; i < bar.getMenuCount(); i++) + labels.add(bar.getMenu(i).getLabel()); + return labels; + } + + @Override + protected List items(final MenuBar bar, final String... path) { + Menu menu = menu(bar, path[0]); + for (int i = 1; i < path.length; i++) + menu = (Menu) item(menu, path[i]); + final List labels = new ArrayList<>(); + for (int i = 0; i < menu.getItemCount(); i++) + labels.add(menu.getItem(i).getLabel()); + return labels; + } + + /** + * An accelerator AWT can express reaches the item; one it cannot is + * dropped rather than approximated into some other key combination. + */ + @Test + public void testShortcutsWithinAwtsMeans() { + final Optional open = AwtMenus.shortcut("^O"); + assertTrue(open.isPresent()); + assertEquals(new MenuShortcut(KeyEvent.VK_O, false), open.get()); + + final Optional shifted = AwtMenus.shortcut("^+C"); + assertTrue(shifted.isPresent()); + assertEquals(new MenuShortcut(KeyEvent.VK_C, true), shifted.get()); + + // NB: MenuShortcut has no alt, and no way to say "no menu modifier". + assertFalse(AwtMenus.shortcut("!N").isPresent()); + assertFalse(AwtMenus.shortcut("F5").isPresent()); + assertFalse(AwtMenus.shortcut("").isPresent()); + + final MenuItem openItem = item(menu(menuBar(), "File"), "Open..."); + assertEquals(new MenuShortcut(KeyEvent.VK_O, false), openItem + .getShortcut()); + } + + // -- Helper methods -- + + private static Menu menu(final MenuBar bar, final String label) { + for (int i = 0; i < bar.getMenuCount(); i++) { + if (bar.getMenu(i).getLabel().equals(label)) return bar.getMenu(i); + } + throw new AssertionError("No such menu: " + label); + } + + private static MenuItem item(final Menu menu, final String label) { + for (int i = 0; i < menu.getItemCount(); i++) { + if (menu.getItem(i).getLabel().equals(label)) return menu.getItem(i); + } + throw new AssertionError("No such item: " + label); + } +} diff --git a/awt/src/test/java/org/scijava/awt/AwtShell.java b/awt/src/test/java/org/scijava/awt/AwtShell.java new file mode 100644 index 000000000..18c12ce8a --- /dev/null +++ b/awt/src/test/java/org/scijava/awt/AwtShell.java @@ -0,0 +1,183 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import java.awt.BorderLayout; +import java.awt.Frame; +import java.awt.Label; +import java.awt.List; +import java.awt.Panel; +import java.awt.TextField; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +import org.scijava.command3.Commands; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; +import org.scijava.context.Context; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Runner; + +/** + * The same small application as the Swing and JavaFX shells, in pure AWT. + *

+ * Run it with: + *

+ * + *
+ * mvn -o -pl scijava-awt test-compile exec:java -Denforcer.skip \
+ *   -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.awt.AwtShell
+ * 
+ *

+ * It is the plainest of the three, because AWT is: no progress bar, no + * scrolling list of matches beyond what {@link List} gives. What it shares + * with the others is everything that matters - the same commands, the same + * menu tree walked by the same {@code Menus}, the same dialogs built by the + * same {@code WidgetPanels}. + *

+ * + * @author Curtis Rueden + */ +public class AwtShell { + + private final Context context = Context.create(); + private final java.util.List commands = entries(); + private final Runner runner; + + private final Frame frame = new Frame("SciJava"); + private final Label status = new Label("Ready"); + private final TextField search = new TextField(24); + private final List matches = new List(8); + + private java.util.List shown = java.util.List.of(); + + public AwtShell() { + runner = Runner.of(java.util.List.of(AwtInputHarvester.of(context)), + java.util.List.of()); + + frame.setMenuBar(AwtMenus.create(MenuTree.of(commands), this::run)); + frame.setLayout(new BorderLayout(4, 4)); + + final Panel searchPane = new Panel(new BorderLayout(4, 0)); + searchPane.add(new Label("Search:"), BorderLayout.WEST); + searchPane.add(search, BorderLayout.CENTER); + frame.add(searchPane, BorderLayout.NORTH); + frame.add(matches, BorderLayout.CENTER); + frame.add(status, BorderLayout.SOUTH); + + search.addTextListener(e -> filter()); + search.addActionListener(e -> runSelected()); + matches.addActionListener(e -> runSelected()); // NB: double-click + frame.addWindowListener(new WindowAdapter() { + + @Override + public void windowClosing(final WindowEvent e) { + System.exit(0); + } + }); + filter(); + frame.setSize(520, 300); + frame.setLocationRelativeTo(null); + } + + public static void main(final String... args) { + Edt.later(() -> new AwtShell().frame.setVisible(true)); + } + + // -- Helper methods -- + + /** Gathers what this application can run: commands, and scripts. */ + private java.util.List entries() { + final java.util.List entries = new java.util.ArrayList<>( + Commands.discover(context)); + final java.net.URL url = AwtShell.class.getResource("/scripts"); + if (url != null) { + try { + new org.scijava.script3.ScriptFinder().find(java.nio.file.Paths.get(url + .toURI())).forEach(found -> entries.add(CommandInfo.of(found.script(), + found.metadata()))); + } + catch (final java.net.URISyntaxException exc) { + // NB: no scripts to add, which is not a reason to fail to start. + } + } + return entries; + } + + /** Narrows the list to the commands whose name contains what was typed. */ + private void filter() { + final String text = search.getText().trim().toLowerCase(Locale.ROOT); + shown = commands.stream() // + .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // + .collect(Collectors.toList()); + matches.removeAll(); + shown.forEach(c -> matches.add(describe(c))); + if (!shown.isEmpty()) matches.select(0); + status.setText(shown.size() + " of " + commands.size() + " commands"); + } + + private void runSelected() { + final int index = matches.getSelectedIndex(); + if (index >= 0 && index < shown.size()) run(shown.get(index)); + } + + /** Runs a command, and says what came back. */ + private void run(final CommandInfo command) { + status.setText("Running " + command.label() + "..."); + final Future future = runner.run(command, Map.of()); + new Thread(() -> { + final String message = describe(future); + Edt.later(() -> status.setText(message)); + }, "shell-" + command.name()).start(); + } + + private static String describe(final CommandInfo command) { + return command.menuPath().map(path -> path.replace(">", " ▸ ")) // + .orElseGet(() -> command.label() + " (not in any menu)"); + } + + private static String describe(final Future future) { + final ExecutionResult result; + try { + result = future.get(); + } + catch (final Exception exc) { + return "Failed: " + exc.getMessage(); + } + if (result.isDeclined()) return result.reason().orElse("Declined"); + if (result.outputs().isEmpty()) return "Done"; + return result.outputs().values().stream().map(String::valueOf) // + .collect(Collectors.joining("; ")); + } +} diff --git a/awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java b/awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java new file mode 100644 index 000000000..1c35afafe --- /dev/null +++ b/awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java @@ -0,0 +1,169 @@ +/* + * #%L + * AWT widgets and platform plumbing, with no Swing anywhere. + * %% + * Copyright (C) 2026 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.awt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Component; +import java.awt.Container; +import java.awt.GraphicsEnvironment; +import java.awt.TextComponent; +import java.awt.TextField; +import java.awt.event.TextEvent; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanel; +import org.scijava.ui3.test.SampleCommands; +import org.scijava.ui3.test.WidgetConformance; + +/** + * Runs the shared conformance suite against the AWT widgets. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class AwtWidgetsTest extends WidgetConformance { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + private static final List> FACTORIES = List.of( // + new AwtMessage.Factory(), // + new AwtChoice.Factory(), // + new AwtNumber.Factory(), // + new AwtToggle.Factory(), // + new AwtFile.Factory(), // + new AwtText.Factory()); + + @Override + protected WidgetPanel panel(final ParameterModel model) { + return build(model, FACTORIES, new AwtPanel.Factory(model)); + } + + @Override + protected Class widgetType(final Kind kind) { + switch (kind) { + case MESSAGE: + return AwtMessage.class; + case TEXT: + return AwtText.class; + case NUMBER: + return AwtNumber.class; + case TOGGLE: + return AwtToggle.class; + case CHOICE: + return AwtChoice.class; + case FILE: + return AwtFile.class; + case PANEL: + return AwtPanel.class; + default: + throw new IllegalArgumentException(String.valueOf(kind)); + } + } + + /** + * Typing in the field runs the callback, which is the path a user takes. + *

+ * NB: AWT has no spinner, so the number widget is a text field - + * and the same conformance suite passes regardless. + *

+ */ + @Test + public void testTypingInTheControlRunsCallback() { + final ParameterModel model = model(new SampleCommands.LinkedValues()); + final AwtPanel panel = (AwtPanel) panel(model); + final AwtNumber celsius = (AwtNumber) panel.widgets().get(0); + + type(celsius, "100.0"); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** Half-typed text is not a value: a lone minus sign writes nothing. */ + @Test + public void testPartialInputIsNotAValue() { + final ParameterModel model = model(new SampleCommands.LinkedValues()); + final AwtPanel panel = (AwtPanel) panel(model); + final AwtNumber celsius = (AwtNumber) panel.widgets().get(0); + + type(celsius, "-"); + + assertEquals(0.0, model.get("celsius")); + assertEquals(32.0, model.get("fahrenheit")); + } + + /** A value outside the declared bounds is clamped, not accepted. */ + @Test + public void testBoundsWithoutASpinner() { + final ParameterModel model = model(new SampleCommands.KitchenSink()); + final AwtPanel panel = (AwtPanel) panel(model); + final AwtNumber percent = (AwtNumber) panel.widgets().get(2); + + type(percent, "500"); + + assertEquals(100, model.get("percent")); + assertTrue(model.problems().isEmpty()); + } + + // -- Helper methods -- + + /** + * Types the given text into a widget's field. + *

+ * NB: {@link TextComponent#setText} does not notify text listeners + * in AWT, where a Swing document would. So the test says what typing says: + * new text, and an event to go with it. + *

+ */ + private static void type(final AwtWidget widget, final String text) { + final TextField field = field(widget.component()); + field.setText(text); + field.dispatchEvent(new TextEvent(field, TextEvent.TEXT_VALUE_CHANGED)); + } + + /** Finds the text field inside a widget's components. */ + private static TextField field(final Component component) { + if (component instanceof TextField) return (TextField) component; + if (component instanceof Container) { + for (final Component child : ((Container) component).getComponents()) { + final TextField found = field(child); + if (found != null) return found; + } + } + return null; + } +} diff --git a/awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy @@ -0,0 +1,3 @@ +#@ double(label = "Radius", min = 0, max = 20, style = "slider,ticks:4") sigma +#@output String result +result = "Blurred with sigma " + sigma diff --git a/awt/src/test/resources/scripts/Say_Hello.groovy b/awt/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/awt/src/test/resources/scripts/Say_Hello.groovy @@ -0,0 +1,4 @@ +#@script(menu = "Help>Say Hello From A Script", accelerator = "^H", weight = 50) +#@ String(value = "world") name +#@output String greeting +greeting = "hello " + name diff --git a/cli/pom.xml b/cli/pom.xml new file mode 100644 index 000000000..3c74029d0 --- /dev/null +++ b/cli/pom.xml @@ -0,0 +1,235 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-cli + + 1.0.0-SNAPSHOT + + SciJava CLI + Running commands and scripts from a command line. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.cli + + bsd_2 + SciJava developers. + + + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + + org.scijava + scijava-command3 + ${scijava-command3.version} + + + org.scijava + scijava-context + ${scijava-context.version} + + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-index + ${scijava-index.version} + + + org.scijava + scijava-script3 + ${scijava-script3.version} + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-convert3 + ${scijava-convert3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/cli/src/main/java/module-info.java b/cli/src/main/java/module-info.java new file mode 100644 index 000000000..fa0a8c787 --- /dev/null +++ b/cli/src/main/java/module-info.java @@ -0,0 +1,47 @@ +/* + * #%L + * Running commands and scripts from a command line. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.cli { + + exports org.scijava.cli; + + // NB: one opens, to the container alone. Nothing in this component's main + // sources is a plugin, but its tests declare commands in this package, and + // surefire patches them into this module -- so without it the container + // cannot construct them. `opens` is not `exports`. + opens org.scijava.cli to org.scijava.context; + + requires transitive org.scijava.command3; + requires transitive org.scijava.execute; + requires transitive org.scijava.script3; + requires org.scijava.common3; + requires org.scijava.context; + requires org.scijava.struct; + +} diff --git a/cli/src/main/java/org/scijava/cli/Arguments.java b/cli/src/main/java/org/scijava/cli/Arguments.java new file mode 100644 index 000000000..8c6ee89f6 --- /dev/null +++ b/cli/src/main/java/org/scijava/cli/Arguments.java @@ -0,0 +1,119 @@ +/* + * #%L + * Running commands and scripts from a command line. + * %% + * Copyright (C) 2026 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.cli; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A command line, taken apart. + *

+ * The grammar is the one every command line has: words, and options that may + * carry a value. + *

+ * + *
+ * scijava Gaussian Blur --sigma 2.5 --edges Wrap
+ * scijava blur.groovy --sigma=2.5
+ * scijava --list filters
+ * 
+ *

+ * NB: an option's value may follow it or be joined with {@code =}, and an + * option with no value is {@code true} - which is what lets a boolean + * parameter be a bare flag. Nothing here knows what any of the options mean; + * {@code scijava-convert3} turns the strings into whatever the parameters + * declared, exactly as it does for a script or a dialog. + *

+ * + * @author Curtis Rueden + */ +public class Arguments { + + private final List words = new ArrayList<>(); + private final Map options = new LinkedHashMap<>(); + + /** Takes apart a command line. */ + public static Arguments parse(final String... args) { + final Arguments parsed = new Arguments(); + for (int i = 0; i < args.length; i++) { + final String arg = args[i]; + if (!arg.startsWith("--")) { + parsed.words.add(arg); + continue; + } + final String option = arg.substring(2); + final int equals = option.indexOf('='); + if (equals >= 0) { + parsed.options.put(option.substring(0, equals), option.substring( + equals + 1)); + continue; + } + // NB: the next argument is this option's value, unless it is itself an + // option or there is nothing left -- in which case the option is a flag. + if (i + 1 < args.length && !args[i + 1].startsWith("--")) { + parsed.options.put(option, args[++i]); + } + else parsed.options.put(option, "true"); + } + return parsed; + } + + /** Gets the words: what to run, and anything else not an option. */ + public List words() { + return List.copyOf(words); + } + + /** Gets the options, in the order they were given. */ + public Map options() { + return Map.copyOf(options); + } + + /** Gets whether the given option was given. */ + public boolean has(final String name) { + return options.containsKey(name); + } + + /** Gets an option's value, or the given default if it was not given. */ + public String get(final String name, final String defaultValue) { + return options.getOrDefault(name, defaultValue); + } + + /** Removes an option, so that what remains is the parameters. */ + public String take(final String name) { + return options.remove(name); + } + + /** Gets the words joined by spaces: a menu path, a label, a class name. */ + public String identifier() { + return String.join(" ", words); + } +} diff --git a/cli/src/main/java/org/scijava/cli/Cli.java b/cli/src/main/java/org/scijava/cli/Cli.java new file mode 100644 index 000000000..2dd72e76f --- /dev/null +++ b/cli/src/main/java/org/scijava/cli/Cli.java @@ -0,0 +1,295 @@ +/* + * #%L + * Running commands and scripts from a command line. + * %% + * Copyright (C) 2026 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.cli; + +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import org.scijava.command3.CommandInfo; +import org.scijava.command3.Commands; +import org.scijava.context.Context; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.ParameterMember; +import org.scijava.execute.Runner; +import org.scijava.script3.ScriptFinder; +import org.scijava.script3.Scripts; +import org.scijava.struct.Member; + +/** + * Running commands and scripts from a command line. + *

+ * The same commands the menus show, run without a display: + *

+ * + *
+ * scijava "Gaussian Blur" --sigma 2.5 --edges Wrap
+ * scijava blur.groovy --sigma=2.5
+ * scijava --list filters
+ * scijava --help "Gaussian Blur"
+ * 
+ *

+ * NB: there is no separate notion of a "headless command" here. A command + * declares its inputs, the command line supplies them, and a run that needs + * something it cannot get is declined with a reason - the same mechanism a + * dismissed dialog uses. SciJava Common needed {@code canRunHeadless()} + * because harvesting was tangled with the UI; here the harvester is simply one + * preprocessor among several, and this runner does not install it. + *

+ * + * @author Curtis Rueden + */ +public class Cli { + + private final List commands; + private final PrintStream out; + private final PrintStream err; + + /** Creates a command line over exactly the given commands. */ + public Cli(final List commands, final PrintStream out, + final PrintStream err) + { + this.commands = List.copyOf(commands); + this.out = out; + this.err = err; + } + + /** + * Runs a command line, and exits with what it returns. + * + * @param args the command line + */ + public static void main(final String... args) { + System.exit(run(args)); + } + + /** + * Runs a command line. + * + * @param args the command line + * @return the exit code: 0 for a completed run, 1 for a failed or declined + * one, 2 for a command line that could not be understood + */ + public static int run(final String... args) { + final Arguments parsed = Arguments.parse(args); + try (Context context = Context.create()) { + final List commands = gather(context, parsed.take( + "scripts")); + return new Cli(commands, System.out, System.err).execute(parsed); + } + } + + /** + * Runs an already-parsed command line. + * + * @param args the command line, with any global options already taken + * @return the exit code + */ + public int execute(final Arguments args) { + if (args.has("list")) { + final String filter = args.take("list"); + list("true".equals(filter) ? null : filter); + return 0; + } + + if (args.has("help")) { + // NB: `--help blur` gives "blur" as the option's value, while + // `blur --help` leaves it a bare flag with "blur" among the words. + // Both mean the same thing and both are what people type. + final String value = args.take("help"); + final String target = "true".equals(value) ? args.identifier() : value; + if (target.isEmpty()) { + usage(); + return 0; + } + final CommandInfo command = resolve(target); + if (command == null) return 2; + describe(command); + return 0; + } + + final String identifier = args.identifier(); + if (identifier.isEmpty()) { + usage(); + return 2; + } + + final CommandInfo command = resolve(identifier); + if (command == null) return 2; + return execute(command, inputs(args)); + } + + // -- Helper methods -- + + /** + * Gathers what this command line can run. + *

+ * NB: the annotation index, and a scripts directory if one was named. The + * legacy bridge is deliberately not wired in here: an application decides + * which worlds it contains, and a command line for SciJava3 alone should + * not drag SciJava Common onto the classpath. + *

+ */ + private static List gather(final Context context, + final String scriptsDirectory) + { + final List commands = new ArrayList<>(Commands.discover( + context)); + if (scriptsDirectory != null) { + final Path directory = Paths.get(scriptsDirectory); + new ScriptFinder().find(directory).forEach(found -> commands.add( + CommandInfo.of(found.script(), found.metadata()))); + } + return commands; + } + + /** Finds the command, or explains why it could not. */ + private CommandInfo resolve(final String identifier) { + // NB: a file that exists is a script, whatever else it might name. + final Path path = Paths.get(identifier); + if (Files.isRegularFile(path)) { + try { + return CommandInfo.of(Scripts.get().of(path), Map.of()); + } + catch (final RuntimeException exc) { + err.println("Cannot read script: " + identifier); + err.println(" " + exc.getMessage()); + return null; + } + } + + final CommandLookup.Result found = new CommandLookup(commands).find( + identifier); + if (found.isFound()) return found.command(); + if (found.isAmbiguous()) { + err.println("Which did you mean?"); + found.matches().forEach(c -> err.println(" " + summarize(c))); + return null; + } + err.println("No such command: " + identifier); + err.println("Try --list to see what there is."); + return null; + } + + /** Runs a command with the given inputs, and reports what came back. */ + private int execute(final CommandInfo command, + final Map inputs) + { + final Runner runner = Runner.of(List.of(), List.of()); + try { + final ExecutionResult result = runner.run(command, inputs).get(); + if (result.isDeclined()) { + err.println("Declined: " + result.reason().orElse("no reason given")); + return 1; + } + result.outputs().forEach((key, value) -> out.println(key + " = " + + value)); + return 0; + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + err.println("Interrupted"); + return 1; + } + catch (final ExecutionException exc) { + final Throwable cause = exc.getCause() == null ? exc : exc.getCause(); + err.println(cause.getMessage() == null ? cause.toString() : cause + .getMessage()); + return 1; + } + } + + /** Gets the options as inputs, which conversion will type on binding. */ + private static Map inputs(final Arguments args) { + return new LinkedHashMap<>(args.options()); + } + + private void list(final String filter) { + final List found = new CommandLookup(commands).list(filter); + found.forEach(c -> out.println(summarize(c))); + out.println(); + out.println(found.size() + " of " + commands.size() + " commands"); + } + + /** Prints what a command takes and what it produces. */ + private void describe(final CommandInfo command) { + out.println(summarize(command)); + final List> members = command.struct().members(); + if (members.isEmpty()) { + out.println(" (no parameters)"); + return; + } + for (final Member member : members) { + final StringBuilder line = new StringBuilder(" "); + line.append(member.isInput() ? "--" + member.key() : "output " + member + .key()); + line.append(" <").append(typeName(member)).append(">"); + if (member instanceof ParameterMember) { + final ParameterMember p = (ParameterMember) member; + p.attr(ParameterMember.LABEL).ifPresent(l -> line.append(" ") + .append(l)); + p.attr(ParameterMember.CHOICES).ifPresent(c -> line.append(" one of: ") + .append(c)); + if (member.isInput() && !member.isRequired()) line.append(" (optional)"); + } + out.println(line); + } + } + + private static String typeName(final Member member) { + final Class raw = org.scijava.common3.Types.raw(member.type()); + return raw == null ? String.valueOf(member.type()) : raw.getSimpleName(); + } + + /** Gets one line naming a command: where it lives, and what it is. */ + private static String summarize(final CommandInfo command) { + // NB: something in no menu is listed by name and said to be so, rather + // than by a label that falls back to the very name beside it. + return command.menuPath().map(p -> p + " [" + command.name() + "]") // + .orElseGet(() -> command.name() + " (not in any menu)"); + } + + private void usage() { + out.println("Usage: scijava [options] [--parameter value ...]"); + out.println(); + out.println(" --list [text] list the commands, optionally filtered"); + out.println(" --help describe a command's parameters"); + out.println(" --scripts also offer the scripts in a directory"); + out.println(); + out.println("A command is named by its menu path, its label, or its"); + out.println("class; a script, by its file name."); + } +} diff --git a/cli/src/main/java/org/scijava/cli/CommandLookup.java b/cli/src/main/java/org/scijava/cli/CommandLookup.java new file mode 100644 index 000000000..7e1b037fd --- /dev/null +++ b/cli/src/main/java/org/scijava/cli/CommandLookup.java @@ -0,0 +1,148 @@ +/* + * #%L + * Running commands and scripts from a command line. + * %% + * Copyright (C) 2026 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.cli; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.scijava.command3.CommandInfo; + +/** + * Finding the command a user meant. + *

+ * People do not type class names. They type what they see in the menus - " + * Gaussian Blur", "Process>Filters>Gaussian Blur..." - or the tail of a + * class name, and they type it in whatever case they please. So the lookup + * tries the exact things first and the forgiving things after, and when two + * commands answer equally it says so rather than picking one. + *

+ * + * @author Curtis Rueden + */ +public class CommandLookup { + + /** What a lookup found: nothing, one thing, or too many things. */ + public static class Result { + + private final List matches; + + Result(final List matches) { + this.matches = matches; + } + + public boolean isFound() { + return matches.size() == 1; + } + + public boolean isAmbiguous() { + return matches.size() > 1; + } + + public CommandInfo command() { + return matches.get(0); + } + + /** Gets everything that answered, for an error message to list. */ + public List matches() { + return List.copyOf(matches); + } + } + + private final List commands; + + public CommandLookup(final List commands) { + this.commands = List.copyOf(commands); + } + + /** + * Finds the command the given text names. + * + * @param text a class name, a menu path, a label, or the tail of any of them + * @return what was found + */ + public Result find(final String text) { + if (text == null || text.isEmpty()) return new Result(List.of()); + + // exactly what it is called + List found = matching(c -> c.name().equals(text)); + if (!found.isEmpty()) return new Result(found); + + // exactly where it lives, or what it is labelled + final String path = text.replace('/', '>'); + found = matching(c -> c.menuPath().map(p -> equalsLoosely(p, path)) // + .orElse(false) || equalsLoosely(c.label(), text)); + if (!found.isEmpty()) return new Result(found); + + // NB: the tail of a class name, which is what a user of an inner class + // would otherwise have to spell with a dollar sign. + found = matching(c -> c.name().endsWith("." + text) || c.name().endsWith( + "$" + text)); + if (!found.isEmpty()) return new Result(found); + + // anything that contains it + return new Result(matching(c -> contains(c.label(), text) || // + c.menuPath().map(p -> contains(p, text)).orElse(false))); + } + + /** Gets the commands whose label or path contains the given text. */ + public List list(final String filter) { + if (filter == null || filter.isEmpty()) return commands; + return matching(c -> contains(c.label(), filter) || // + c.menuPath().map(p -> contains(p, filter)).orElse(false) || // + contains(c.name(), filter)); + } + + // -- Helper methods -- + + private List matching( + final java.util.function.Predicate test) + { + final List found = new ArrayList<>(); + for (final CommandInfo command : commands) { + if (test.test(command)) found.add(command); + } + return found; + } + + /** Compares ignoring case, spacing and a trailing ellipsis. */ + private static boolean equalsLoosely(final String a, final String b) { + return normalize(a).equals(normalize(b)); + } + + private static boolean contains(final String haystack, final String needle) { + return haystack != null && normalize(haystack).contains(normalize(needle)); + } + + private static String normalize(final String text) { + return text == null ? "" : text.toLowerCase(Locale.ROOT) // + .replace("...", "").replace(" ", ""); + } +} diff --git a/cli/src/test/java/org/scijava/cli/CliCommands.java b/cli/src/test/java/org/scijava/cli/CliCommands.java new file mode 100644 index 000000000..07f606f4f --- /dev/null +++ b/cli/src/test/java/org/scijava/cli/CliCommands.java @@ -0,0 +1,150 @@ +/* + * #%L + * Running commands and scripts from a command line. + * %% + * Copyright (C) 2026 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.cli; + +import java.io.File; + +import org.scijava.command3.Command; +import org.scijava.command3.Menu; +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; +import org.scijava.struct.ItemIO; + +/** + * Commands for the command line to find and run. + * + * @author Curtis Rueden + */ +public final class CliCommands { + + private CliCommands() { + // prevent instantiation of utility class + } + + @Plugin(type = Command.class) + @Menu(path = "Process>Filters>Gaussian Blur...", weight = 20) + public static class GaussianBlur implements Command { + + @Parameter(label = "Blur radius", min = "0", max = "20") + private double sigma = 2; + + @Parameter(choices = { "Reflect", "Zero", "Wrap" }) + private String edges = "Reflect"; + + @Parameter(required = false) + private File input; + + @Parameter(io = ItemIO.OUTPUT) + private String result; + + @Override + public void run() { + result = "blurred sigma=" + sigma + " edges=" + edges; + } + } + + @Plugin(type = Command.class) + @Menu(path = "Analyze>Measure...", weight = 30) + public static class Measure implements Command { + + @Parameter + private String column = "X"; + + @Parameter(io = ItemIO.OUTPUT) + private String measured; + + @Override + public void run() { + measured = "measured " + column; + } + } + + /** Two commands whose labels overlap, to make a lookup ambiguous. */ + @Plugin(type = Command.class) + @Menu(path = "Image>Adjust>Brightness...", weight = 10) + public static class Brightness implements Command { + + @Parameter + private int level = 50; + + @Parameter(io = ItemIO.OUTPUT) + private String adjusted; + + @Override + public void run() { + adjusted = "brightness " + level; + } + } + + @Plugin(type = Command.class) + @Menu(path = "Image>Adjust>Brightness/Contrast...", weight = 11) + public static class BrightnessContrast implements Command { + + @Parameter + private int level = 50; + + @Parameter(io = ItemIO.OUTPUT) + private String adjusted; + + @Override + public void run() { + adjusted = "brightness/contrast " + level; + } + } + + /** A command that declines, to show what that looks like from a shell. */ + @Plugin(type = Command.class) + @Menu(path = "File>Save", weight = 1) + public static class Save implements Command { + + @Parameter + private boolean fail = false; + + @Parameter(io = ItemIO.OUTPUT) + private String saved; + + @Override + public void run() { + if (fail) throw new IllegalStateException("Nothing to save"); + saved = "saved"; + } + } + + /** Runnable, but in no menu: reachable by name and nothing else. */ + @Plugin(type = Command.class) + public static class Hidden implements Command { + + @Parameter(io = ItemIO.OUTPUT) + private String ran = "the hidden command ran"; + + @Override + public void run() {} + } +} diff --git a/cli/src/test/java/org/scijava/cli/CliTest.java b/cli/src/test/java/org/scijava/cli/CliTest.java new file mode 100644 index 000000000..833225c8a --- /dev/null +++ b/cli/src/test/java/org/scijava/cli/CliTest.java @@ -0,0 +1,238 @@ +/* + * #%L + * Running commands and scripts from a command line. + * %% + * Copyright (C) 2026 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.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.Commands; +import org.scijava.context.Context; + +/** + * Tests running commands from a command line. + * + * @author Curtis Rueden + */ +public class CliTest { + + /** A command is named by its menu path, and its inputs are options. */ + @Test + public void testRunByMenuPath() { + final Run run = cli("Process>Filters>Gaussian Blur...", "--sigma", "2.5", + "--edges", "Wrap"); + + assertEquals(0, run.code); + assertEquals("result = blurred sigma=2.5 edges=Wrap", run.out.trim()); + } + + /** Or by its label, in whatever case, ellipsis or no ellipsis. */ + @Test + public void testRunByLabel() { + assertEquals("result = blurred sigma=2.0 edges=Reflect", cli( + "gaussian blur").out.trim()); + } + + /** Or by the tail of its class name, which is what a script would use. */ + @Test + public void testRunByClassName() { + assertEquals("ran = the hidden command ran", cli("Hidden").out.trim()); + } + + /** An option's value may follow it or be joined with an equals sign. */ + @Test + public void testEqualsSyntax() { + assertEquals("result = blurred sigma=3.0 edges=Reflect", cli( + "gaussian blur", "--sigma=3").out.trim()); + } + + /** A bare option is true, which is what a boolean parameter wants. */ + @Test + public void testFlagSyntax() { + final Run run = cli("File>Save", "--fail"); + + assertEquals(1, run.code, run.err); + assertTrue(run.err.contains("Nothing to save"), run.err); + } + + /** Strings from the shell become whatever the parameters declared. */ + @Test + public void testInputsAreConverted() { + // NB: "2.5" reaches a double, and convert3 did it -- the same conversion + // a script or a dialog gets. + assertTrue(cli("gaussian blur", "--sigma", "2.5").out.contains( + "sigma=2.5")); + } + + /** An unknown command says so, and says how to look. */ + @Test + public void testUnknownCommand() { + final Run run = cli("no-such-thing"); + + assertEquals(2, run.code); + assertTrue(run.err.contains("No such command"), run.err); + assertTrue(run.err.contains("--list"), run.err); + } + + /** An ambiguous one lists the candidates rather than guessing. */ + @Test + public void testAmbiguousCommand() { + final Run run = cli("Bright"); + + assertEquals(2, run.code); + assertTrue(run.err.contains("Which did you mean?"), run.err); + assertTrue(run.err.contains("Brightness..."), run.err); + assertTrue(run.err.contains("Brightness/Contrast..."), run.err); + } + + /** + * An exact label wins over a partial one, so the more specific command + * being present does not make the plainer one unreachable. + */ + @Test + public void testExactLabelBeatsPartial() { + final Run run = cli("Brightness", "--level", "10"); + + assertEquals(0, run.code, run.err); + assertEquals("adjusted = brightness 10", run.out.trim()); + } + + /** Listing shows what there is, filtered if asked. */ + @Test + public void testList() { + final Run all = cli("--list"); + assertTrue(all.out.contains("Gaussian Blur..."), all.out); + assertTrue(all.out.contains("Measure..."), all.out); + + final Run filtered = cli("--list", "filters"); + assertTrue(filtered.out.contains("Gaussian Blur..."), filtered.out); + assertTrue(!filtered.out.contains("Measure..."), filtered.out); + } + + /** Help describes a command's parameters, from the same metadata. */ + @Test + public void testHelpForACommand() { + final Run run = cli("--help", "gaussian blur"); + + assertEquals(0, run.code); + assertTrue(run.out.contains("--sigma "), run.out); + assertTrue(run.out.contains("Blur radius"), run.out); + assertTrue(run.out.contains("one of: Reflect,Zero,Wrap"), run.out); + assertTrue(run.out.contains("(optional)"), run.out); + } + + /** A script file is run by its path, no menu or index involved. */ + @Test + public void testRunAScript(@TempDir final Path dir) throws Exception { + final Path script = dir.resolve("greet.groovy"); + Files.write(script, ("" + // + "#@ String name\n" + // + "#@output String greeting\n" + // + "greeting = \"hello \" + name\n").getBytes(StandardCharsets.UTF_8)); + + final Run run = cli(script.toString(), "--name", "ada"); + + assertEquals(0, run.code, run.err); + assertEquals("greeting = hello ada", run.out.trim()); + } + + /** Scripts in a directory join the commands, and are named as commands. */ + @Test + public void testScriptsDirectory(@TempDir final Path dir) throws Exception { + final Path scripts = Files.createDirectories(dir.resolve("Plugins")); + Files.write(scripts.resolve("Say_Hello.groovy"), ("" + // + "#@ String(value = \"world\") name\n" + // + "#@output String greeting\n" + // + "greeting = \"hello \" + name\n").getBytes(StandardCharsets.UTF_8)); + + final Run run = cli(dir, "Plugins>Say Hello"); + + assertEquals(0, run.code, run.err); + assertEquals("greeting = hello world", run.out.trim()); + } + + /** With nothing to run, it explains itself rather than doing nothing. */ + @Test + public void testUsage() { + final Run run = cli(); + + assertEquals(2, run.code); + assertTrue(run.out.contains("Usage: scijava"), run.out); + } + + // -- Helper methods -- + + /** What a run produced. */ + private static class Run { + + int code; + String out; + String err; + } + + private static Run cli(final String... args) { + return cli(null, args); + } + + /** Runs a command line over the test commands, and captures its output. */ + private static Run cli(final Path scriptsDirectory, final String... args) { + final Run run = new Run(); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final ByteArrayOutputStream err = new ByteArrayOutputStream(); + try (Context context = Context.create(); + PrintStream outStream = new PrintStream(out, true, + StandardCharsets.UTF_8); + PrintStream errStream = new PrintStream(err, true, + StandardCharsets.UTF_8)) + { + final List commands = new java.util.ArrayList<>(Commands + .discover(context)); + if (scriptsDirectory != null) { + new org.scijava.script3.ScriptFinder().find(scriptsDirectory).forEach( + found -> commands.add(CommandInfo.of(found.script(), found + .metadata()))); + } + run.code = new Cli(commands, outStream, errStream).execute(Arguments + .parse(args)); + } + run.out = new String(out.toByteArray(), StandardCharsets.UTF_8); + run.err = new String(err.toByteArray(), StandardCharsets.UTF_8); + return run; + } +} diff --git a/scijava-collections/.gitignore b/collections/.gitignore similarity index 100% rename from scijava-collections/.gitignore rename to collections/.gitignore diff --git a/scijava-collections/LICENSE.txt b/collections/LICENSE.txt similarity index 100% rename from scijava-collections/LICENSE.txt rename to collections/LICENSE.txt diff --git a/collections/README.md b/collections/README.md new file mode 100644 index 000000000..85391b021 --- /dev/null +++ b/collections/README.md @@ -0,0 +1,5 @@ +# SciJava Collections + +Useful implementations of `java.util.Collection`, e.g. for primitive arrays. + +Not as full-featured as Trove, but simple. diff --git a/scijava-collections/pom.xml b/collections/pom.xml similarity index 100% rename from scijava-collections/pom.xml rename to collections/pom.xml diff --git a/scijava-collections/src/main/java/module-info.java b/collections/src/main/java/module-info.java similarity index 100% rename from scijava-collections/src/main/java/module-info.java rename to collections/src/main/java/module-info.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/AbstractPrimitiveArray.java b/collections/src/main/java/org/scijava/collections/AbstractPrimitiveArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/AbstractPrimitiveArray.java rename to collections/src/main/java/org/scijava/collections/AbstractPrimitiveArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/BoolArray.java b/collections/src/main/java/org/scijava/collections/BoolArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/BoolArray.java rename to collections/src/main/java/org/scijava/collections/BoolArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/ByteArray.java b/collections/src/main/java/org/scijava/collections/ByteArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/ByteArray.java rename to collections/src/main/java/org/scijava/collections/ByteArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/CharArray.java b/collections/src/main/java/org/scijava/collections/CharArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/CharArray.java rename to collections/src/main/java/org/scijava/collections/CharArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/DoubleArray.java b/collections/src/main/java/org/scijava/collections/DoubleArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/DoubleArray.java rename to collections/src/main/java/org/scijava/collections/DoubleArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/FloatArray.java b/collections/src/main/java/org/scijava/collections/FloatArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/FloatArray.java rename to collections/src/main/java/org/scijava/collections/FloatArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/IntArray.java b/collections/src/main/java/org/scijava/collections/IntArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/IntArray.java rename to collections/src/main/java/org/scijava/collections/IntArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/LongArray.java b/collections/src/main/java/org/scijava/collections/LongArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/LongArray.java rename to collections/src/main/java/org/scijava/collections/LongArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/ObjectArray.java b/collections/src/main/java/org/scijava/collections/ObjectArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/ObjectArray.java rename to collections/src/main/java/org/scijava/collections/ObjectArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/PrimitiveArray.java b/collections/src/main/java/org/scijava/collections/PrimitiveArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/PrimitiveArray.java rename to collections/src/main/java/org/scijava/collections/PrimitiveArray.java diff --git a/scijava-collections/src/main/java/org/scijava/collections/ShortArray.java b/collections/src/main/java/org/scijava/collections/ShortArray.java similarity index 100% rename from scijava-collections/src/main/java/org/scijava/collections/ShortArray.java rename to collections/src/main/java/org/scijava/collections/ShortArray.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/BoolArrayTest.java b/collections/src/test/java/org/scijava/collections/BoolArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/BoolArrayTest.java rename to collections/src/test/java/org/scijava/collections/BoolArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/ByteArrayTest.java b/collections/src/test/java/org/scijava/collections/ByteArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/ByteArrayTest.java rename to collections/src/test/java/org/scijava/collections/ByteArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/CharArrayTest.java b/collections/src/test/java/org/scijava/collections/CharArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/CharArrayTest.java rename to collections/src/test/java/org/scijava/collections/CharArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/DoubleArrayTest.java b/collections/src/test/java/org/scijava/collections/DoubleArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/DoubleArrayTest.java rename to collections/src/test/java/org/scijava/collections/DoubleArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/FloatArrayTest.java b/collections/src/test/java/org/scijava/collections/FloatArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/FloatArrayTest.java rename to collections/src/test/java/org/scijava/collections/FloatArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/IntArrayTest.java b/collections/src/test/java/org/scijava/collections/IntArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/IntArrayTest.java rename to collections/src/test/java/org/scijava/collections/IntArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/LongArrayTest.java b/collections/src/test/java/org/scijava/collections/LongArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/LongArrayTest.java rename to collections/src/test/java/org/scijava/collections/LongArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/ObjectArrayTest.java b/collections/src/test/java/org/scijava/collections/ObjectArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/ObjectArrayTest.java rename to collections/src/test/java/org/scijava/collections/ObjectArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/PrimitiveArrayTest.java b/collections/src/test/java/org/scijava/collections/PrimitiveArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/PrimitiveArrayTest.java rename to collections/src/test/java/org/scijava/collections/PrimitiveArrayTest.java diff --git a/scijava-collections/src/test/java/org/scijava/collections/ShortArrayTest.java b/collections/src/test/java/org/scijava/collections/ShortArrayTest.java similarity index 100% rename from scijava-collections/src/test/java/org/scijava/collections/ShortArrayTest.java rename to collections/src/test/java/org/scijava/collections/ShortArrayTest.java diff --git a/command3/pom.xml b/command3/pom.xml new file mode 100644 index 000000000..0b2c96a9f --- /dev/null +++ b/command3/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-command3 + 3.0.0-SNAPSHOT + + SciJava Command + Discoverable commands, with the metadata a menu is built from. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.command3 + + bsd_2 + SciJava developers. + + + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-context + ${scijava-context.version} + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-index + ${scijava-index.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/command3/src/main/java/module-info.java b/command3/src/main/java/module-info.java new file mode 100644 index 000000000..6ed5496c4 --- /dev/null +++ b/command3/src/main/java/module-info.java @@ -0,0 +1,47 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.command3 { + + exports org.scijava.command3; + + // NB: one opens, to the container alone. The execution layer reads + // @Parameter fields through a lookup the context supplies, so it needs no + // opens of its own. `opens` is not `exports` -- these classes stay invisible + // to ordinary callers regardless. + opens org.scijava.command3 to org.scijava.context; + + requires transitive org.scijava.context; + requires transitive org.scijava.execute; + requires transitive org.scijava.struct; + requires org.scijava.discovery; + requires org.scijava.index; + requires org.scijava.priority; + +} diff --git a/command3/src/main/java/org/scijava/command3/Accelerator.java b/command3/src/main/java/org/scijava/command3/Accelerator.java new file mode 100644 index 000000000..961b5d612 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/Accelerator.java @@ -0,0 +1,125 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import java.util.Optional; + +/** + * A keyboard shortcut, as {@link Menu#accelerator()} writes it. + *

+ * The notation is ImageJ's, because that is what tens of thousands of existing + * commands are written in: {@code ^} is the platform's menu shortcut - control, + * or command on a Mac - {@code !} is alt and {@code +} is shift, followed by + * the key. So {@code "^+C"} is shift-ctrl-C, or shift-cmd-C. + *

+ *

+ * NB: parsing it here rather than in each binding is a lesson from the third + * one: Swing wants a {@code KeyStroke}, JavaFX a {@code KeyCombination}, and + * AWT a {@code MenuShortcut} that cannot express alt at all. Those are three + * ways of saying a shortcut; what the shortcut is was the + * same each time, and a binding that has to parse the string again is a + * binding that can disagree about what it means. + *

+ * + * @author Curtis Rueden + */ +public final class Accelerator { + + private final String key; + private final boolean shortcut; + private final boolean alt; + private final boolean shift; + + private Accelerator(final String key, final boolean shortcut, + final boolean alt, final boolean shift) + { + this.key = key; + this.shortcut = shortcut; + this.alt = alt; + this.shift = shift; + } + + /** + * Reads an accelerator. + * + * @param accelerator the shortcut, e.g. {@code "^O"} or {@code "^+C"} + * @return the accelerator, or empty if there is nothing to read + */ + public static Optional parse(final String accelerator) { + if (accelerator == null || accelerator.isEmpty()) return Optional.empty(); + boolean shortcut = false, alt = false, shift = false; + int i = 0; + for (; i < accelerator.length(); i++) { + final char c = accelerator.charAt(i); + if (c == '^') shortcut = true; + else if (c == '!') alt = true; + else if (c == '+') shift = true; + else break; + } + final String key = accelerator.substring(i); + if (key.isEmpty()) return Optional.empty(); + return Optional.of(new Accelerator(key, shortcut, alt, shift)); + } + + /** Gets the key itself, without modifiers: {@code "O"}, {@code "F1"}. */ + public String key() { + return key; + } + + /** Gets whether the key is one character, as a shortcut usually is. */ + public boolean isCharacter() { + return key.length() == 1; + } + + /** Gets whether the platform's menu shortcut - control, or command. */ + public boolean isShortcut() { + return shortcut; + } + + /** Gets whether alt is held. */ + public boolean isAlt() { + return alt; + } + + /** Gets whether shift is held. */ + public boolean isShift() { + return shift; + } + + /** Gets whether any modifier is held. */ + public boolean hasModifiers() { + return shortcut || alt || shift; + } + + @Override + public String toString() { + return (shortcut ? "^" : "") + (alt ? "!" : "") + (shift ? "+" : "") + key; + } +} diff --git a/command3/src/main/java/org/scijava/command3/ClassCommandInfo.java b/command3/src/main/java/org/scijava/command3/ClassCommandInfo.java new file mode 100644 index 000000000..c0235b4f2 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/ClassCommandInfo.java @@ -0,0 +1,176 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import java.util.Map; +import java.util.Optional; + +import org.scijava.context.Access; +import org.scijava.discovery.Discovery; +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.Executables; +import org.scijava.struct.Struct; + +/** + * A command as the application knows it before running one: its identity, its + * parameters, and how it should appear in a menu. + *

+ * The presentation metadata is read from the annotation index, so listing + * every command and building a menu from them loads no command classes at all. + * {@link #struct()} and {@link #create()} are the points at which the class is + * finally needed. + *

+ *

+ * NB: SciJava Common called this {@code ModuleInfo}, and its + * {@code ClassCommandInfo} was one implementation. The names have shifted by one: + * "module" is now a JPMS module, so the general description is + * {@link Executable}, and this is the command-shaped view of it. + *

+ *

+ * NB: a command whose implementation is a class - which is not the + * same as a Java class, Kotlin and Groovy and Scala classes being just as + * welcome. The other kind is a script, which reaches the same menus as a + * {@link CommandInfo} built by {@link CommandInfo#of}. + *

+ * + * @author Curtis Rueden + */ +public class ClassCommandInfo implements CommandInfo { + + private final Discovery discovery; + private final Map menu; + private Executable delegate; + + ClassCommandInfo(final Discovery discovery, + final Map menu) + { + this.discovery = discovery; + this.menu = menu; + } + + // -- Metadata, readable without loading the class -- + + /** Gets the command's implementation class name. */ + public String className() { + return discovery.implClassName(); + } + + /** Gets where this command sits in the menus, if anywhere. */ + @Override + public Optional menuPath() { + return attr("path"); + } + + /** Gets the label to display, defaulting to the last menu path element. */ + @Override + public String label() { + return menuPath() // + .map(path -> path.substring(path.lastIndexOf('>') + 1)) // + .orElseGet(this::name); + } + + /** Gets this command's keyboard shortcut, if it has one. */ + @Override + public Optional accelerator() { + return attr("accelerator"); + } + + /** Gets this command's icon resource, if it has one. */ + @Override + public Optional iconPath() { + return attr("iconPath"); + } + + /** Gets how this command sorts among its menu siblings. */ + @Override + public double weight() { + return attr("weight").map(Double::parseDouble) // + .orElse(Double.POSITIVE_INFINITY); + } + + /** Gets whether this command should appear in menus. */ + @Override + public boolean isVisible() { + return !"false".equals(menu.get("visible")); + } + + /** Gets the priority with which this command was declared. */ + public double priority() { + return discovery.priority(); + } + + /** Gets a menu attribute by name. */ + public Optional attr(final String key) { + final String value = menu.get(key); + return value == null || value.isEmpty() ? Optional.empty() // + : Optional.of(value); + } + + // -- Executable methods -- + + @Override + public String name() { + return className(); + } + + @Override + public Struct struct() { + return delegate().struct(); + } + + @Override + public ExecutableInstance create() { + return delegate().create(); + } + + @Override + public String toString() { + return className() + menuPath().map(p -> " [" + p + "]").orElse(""); + } + + /** + * Gets the executable behind this command, loading the class on first use. + *

+ * NB: everything above this line answers from the index alone. This is + * where a menu stops being free. + *

+ */ + private synchronized Executable delegate() { + if (delegate == null) { + final Class type = discovery.type(); + // NB: the lookup comes from the context, so a command's package need + // only be opened to org.scijava.context -- not additionally to the + // execution layer that reads its @Parameter fields. + delegate = Executables.of(type, Access.lookupIn(type)); + } + return delegate; + } +} diff --git a/command3/src/main/java/org/scijava/command3/Command.java b/command3/src/main/java/org/scijava/command3/Command.java new file mode 100644 index 000000000..d65a3d201 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/Command.java @@ -0,0 +1,76 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +/** + * Something a user can run: the unit a menu item, a script and a recorded + * step all refer to. + *

+ * A Java command is a class implementing this interface, declaring its + * parameters as {@code org.scijava.execute.Parameter} fields, and declaring + * itself with {@code @Plugin(type = Command.class)}: + *

+ * + *
+ * @Plugin(type = Command.class)
+ * @Menu(path = "Image>Adjust>Brightness/Contrast...", weight = 12)
+ * public class BrightnessContrast implements Command {
+ *
+ * 	@Parameter
+ * 	private Dataset image;
+ *
+ * 	public void run() { ... }
+ * }
+ * 
+ *

On the word

+ *

+ * "Command" here means what SciJava Common called a module: anything + * a user can run, with declared parameters. A script is a command whose + * implementation is source in some language; a class like the one above is a + * command whose implementation is a class. Neither is a special case of the + * other, and both reach the menu, the harvester and the runner by one path. + *

+ *

+ * This interface is therefore not the definition of a command - {@link + * CommandInfo} is - but the way to write one as a class. A script + * implements nothing at all, and is no less a command for it. + *

+ *

+ * The old vocabulary is retired: module now means a JPMS module, + * macro means the ImageJ 1.x macro language and nothing here, and + * extension is not a technical term. + *

+ * + * @author Curtis Rueden + */ +public interface Command extends Runnable { + // NB: no methods of its own. A command is a Runnable that declares + // parameters, and the parameters are declared by annotation. +} diff --git a/command3/src/main/java/org/scijava/command3/CommandInfo.java b/command3/src/main/java/org/scijava/command3/CommandInfo.java new file mode 100644 index 000000000..caa240e72 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/CommandInfo.java @@ -0,0 +1,192 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import java.util.Map; +import java.util.Optional; + +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.struct.Struct; + +/** + * A command: something a user can run, and what is needed to present it. + *

+ * This is the word's whole meaning here. A {@link ClassCommandInfo} is a + * command whose implementation is a class, read from the annotation index; a + * script is a command whose implementation is source in some language, and + * says the same things in its {@code #@script} directives; a SciJava Common + * {@code ModuleInfo} will be a command through the legacy bridge. None is a + * special case of the others, and an application gathers them from as many + * sources as it has without anything downstream learning there was more than + * one. + *

+ *

+ * NB: SciJava Common called this a module, and had to, because + * "command" there meant the Java-class kind specifically. Naming the general + * thing after what users actually call it costs one word - the class-based + * kind is a {@code ClassCommandInfo} - and retires a vocabulary nobody + * enjoyed. + *

+ *

+ * NB: a menu path is optional, and this is not a menu entry. + * Plenty of runnable things belong in no menu: a command invoked by name from + * a script, one reached only through a search bar, one that exists to be + * called by something else. {@link MenuTree} takes the subset that has a path, + * and the rest stay perfectly runnable - which is why this is named for what + * it is rather than for the one place it is most often shown. + *

+ *

+ * NB: it is itself an {@link Executable}, so launching one needs no lookup: + * what the menu - or the search bar, or the command line - holds is the thing + * to run. + *

+ * + * @author Curtis Rueden + */ +public interface CommandInfo extends Executable { + + /** + * Gets where this sits in the menus, if anywhere. + *

+ * Empty means it appears in no menu, which is an ordinary thing to be. + *

+ */ + Optional menuPath(); + + /** Gets the label to display. */ + String label(); + + /** Gets the keyboard shortcut, if there is one. */ + default Optional accelerator() { + return Optional.empty(); + } + + /** Gets the icon resource, if there is one. */ + default Optional iconPath() { + return Optional.empty(); + } + + /** Gets how this sorts among its menu siblings; lower comes first. */ + default double weight() { + return Double.POSITIVE_INFINITY; + } + + /** Gets whether this should appear in menus at all. */ + default boolean isVisible() { + return true; + } + + /** + * Describes anything runnable, from metadata written the way a + * {@code @Menu} annotation writes it. + *

+ * This is what a script's {@code #@script(...)} directives become, and what + * any other source of runnable things can produce without knowing about + * commands at all. Metadata it does not carry - a menu path included - is + * simply absent. + *

+ * + * @param executable the thing to run - a script, typically + * @param attrs its presentation metadata: {@code menu} or {@code path}, + * {@code label}, {@code accelerator}, {@code iconPath}, + * {@code weight}, {@code visible}. All of it is optional, + * including the menu path + * @return the description + */ + static CommandInfo of(final Executable executable, + final Map attrs) + { + return new CommandInfo() { + + @Override + public Optional menuPath() { + // NB: `menu` is what a script writer types; `path` is what the + // annotation calls it. Both mean the same thing, and insisting on + // one of them would only be a way to be unhelpful. + return attr("menu").or(() -> attr("path")); + } + + @Override + public String label() { + return attr("label") // + .or(() -> menuPath().map(p -> p.substring(p.lastIndexOf('>') + 1))) // + .orElseGet(executable::name); + } + + @Override + public Optional accelerator() { + return attr("accelerator"); + } + + @Override + public Optional iconPath() { + return attr("iconPath"); + } + + @Override + public double weight() { + return attr("weight").map(Double::parseDouble) // + .orElse(Double.POSITIVE_INFINITY); + } + + @Override + public boolean isVisible() { + return !"false".equals(attrs.get("visible")); + } + + @Override + public String name() { + return attr("name").orElseGet(executable::name); + } + + @Override + public Struct struct() { + return executable.struct(); + } + + @Override + public ExecutableInstance create() { + return executable.create(); + } + + @Override + public String toString() { + return name() + menuPath().map(p -> " [" + p + "]").orElse(""); + } + + private Optional attr(final String key) { + final String value = attrs.get(key); + return value == null || value.isEmpty() ? Optional.empty() // + : Optional.of(value); + } + }; + } +} diff --git a/command3/src/main/java/org/scijava/command3/Commands.java b/command3/src/main/java/org/scijava/command3/Commands.java new file mode 100644 index 000000000..80d3dca0f --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/Commands.java @@ -0,0 +1,107 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.scijava.context.Context; +import org.scijava.discovery.Discovery; +import org.scijava.index.Index; +import org.scijava.index.IndexItem; + +/** + * Finds the commands available to an application. + *

+ * Commands are declared with {@code @Plugin(type = Command.class)} and, where + * they belong in a menu, {@link Menu}. Those are two separate indexes, joined + * here on the implementation class name - so listing every command, with its + * menu path, label, icon and accelerator, loads no command + * classes. + *

+ * + * @author Curtis Rueden + */ +public final class Commands { + + private Commands() { + // NB: prevent instantiation of utility class. + } + + /** + * Lists the commands available to the given context. + * + * @param context the application context + * @return the commands, ordered by menu weight and then by label + */ + public static List discover(final Context context) { + final Map> menus = menusByClassName( // + Thread.currentThread().getContextClassLoader()); + + final List commands = new ArrayList<>(); + for (final Discovery discovery : context.plugins(Command.class)) { + commands.add(new ClassCommandInfo(discovery, menus.getOrDefault( // + discovery.implClassName(), Map.of()))); + } + commands.sort(Comparator.comparingDouble(ClassCommandInfo::weight) // + .thenComparing(ClassCommandInfo::label)); + return commands; + } + + /** + * Reads the {@link Menu} index, keyed by implementation class name. + *

+ * NB: this is the join that lets presentation metadata stack onto a plugin + * declaration instead of living inside it. Each annotation has its own + * index; both are keyed by the class they annotate. + *

+ */ + private static Map> menusByClassName( + final ClassLoader classLoader) + { + final Map> menus = new HashMap<>(); + for (final IndexItem item : Index.load(Menu.class, classLoader)) { + final Menu menu = item.annotation(); + final Map attrs = new HashMap<>(); + attrs.put("path", menu.path()); + attrs.put("weight", Double.toString(menu.weight())); + attrs.put("accelerator", menu.accelerator()); + attrs.put("iconPath", menu.iconPath()); + attrs.put("selectable", Boolean.toString(menu.selectable())); + attrs.put("selectionGroup", menu.selectionGroup()); + attrs.put("visible", Boolean.toString(menu.visible())); + menus.put(item.className(), attrs); + } + return menus; + } +} diff --git a/command3/src/main/java/org/scijava/command3/Menu.java b/command3/src/main/java/org/scijava/command3/Menu.java new file mode 100644 index 000000000..eaa6353d3 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/Menu.java @@ -0,0 +1,100 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.scijava.index.Indexable; + +/** + * Where a command appears in the menus, and how it looks there. + *

+ * This stacks onto a plugin declaration rather than living inside it: + *

+ * + *
+ * @Plugin(type = Command.class)
+ * @Menu(path = "Image>Adjust>Brightness/Contrast...", weight = 12,
+ *       accelerator = "^C", iconPath = "/icons/bc.png")
+ * public class BrightnessContrast implements Command { ... }
+ * 
+ *

+ * Keeping it separate is what stops {@code @Plugin} from accumulating + * attributes that make no sense for most plugin types - a converter has no + * menu path - while leaving {@code @Plugin} the single, uniform way to declare + * a plugin. + *

+ *

+ * NB: this is {@link Indexable} in its own right, so the annotation processor + * writes it to its own index file. A menu is therefore built by reading two + * indexes and joining them on the class name, with no class + * loading - which is what keeps startup cheap when an application has + * hundreds of commands. An {@code @Menu} without a {@code @Plugin} is + * harmless but inert: nothing discovers it. + *

+ * + * @author Curtis Rueden + */ +@Indexable +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Menu { + + /** + * Where this command sits, as a {@code >}-separated path whose last element + * is the item itself - for example + * {@code "Image>Adjust>Brightness/Contrast..."}. + */ + String path(); + + /** Sorts items within their menu; lower comes first. */ + double weight() default Double.POSITIVE_INFINITY; + + /** + * A keyboard shortcut, in the SciJava Common notation: {@code ^} for the + * platform's command key, {@code !} for shift, {@code @} for alt. + */ + String accelerator() default ""; + + /** A classpath resource for the item's icon. */ + String iconPath() default ""; + + /** Whether this item is a toggle. */ + boolean selectable() default false; + + /** Groups mutually exclusive toggles; implies {@link #selectable()}. */ + String selectionGroup() default ""; + + /** Whether this item appears at all. */ + boolean visible() default true; +} diff --git a/command3/src/main/java/org/scijava/command3/MenuCreator.java b/command3/src/main/java/org/scijava/command3/MenuCreator.java new file mode 100644 index 000000000..54d8a7349 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/MenuCreator.java @@ -0,0 +1,72 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +/** + * Builds a toolkit's menus from a {@link MenuTree}. + *

+ * The walk itself is the same everywhere - descend the tree, make a menu for + * each branch and an item for each leaf - so {@link Menus} does it, and a + * toolkit implements only the four things it alone can do. What an item + * does when chosen is the implementation's business, typically a + * callback it was constructed with. + *

+ *

+ * NB: a menu bar and a menu are different types in every toolkit worth + * supporting, which is why there are two type parameters and why adding to the + * bar is separate from adding to a menu. + *

+ * + * @param the toolkit's menu bar + * @param the toolkit's menu + * @author Curtis Rueden + */ +public interface MenuCreator { + + /** Adds a top-level menu to the bar. */ + M topMenu(MenuTree node, B bar); + + /** Adds a menu inside another menu. */ + M subMenu(MenuTree node, M parent); + + /** Adds a command to a menu. */ + void item(MenuTree leaf, M parent); + + /** + * Adds a command directly to the bar, for a one-element menu path. + *

+ * Rare, and some toolkits cannot do it at all; the default puts the command + * in a menu of its own name rather than losing it. + *

+ */ + default void topItem(final MenuTree leaf, final B bar) { + item(leaf, topMenu(leaf, bar)); + } +} diff --git a/command3/src/main/java/org/scijava/command3/MenuTree.java b/command3/src/main/java/org/scijava/command3/MenuTree.java new file mode 100644 index 000000000..66b3920b2 --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/MenuTree.java @@ -0,0 +1,210 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * A tree of menu entries, built from the menu paths of commands. + *

+ * This is a model, not a widget: it knows about labels, order, shortcuts and + * icons, and nothing about Swing, JavaFX or the platform. A user interface + * walks it and builds whatever its toolkit calls a menu. + *

+ *

+ * Building one loads no command classes. Every entry here comes from the + * annotation index, and the command behind a leaf is loaded when somebody + * actually runs it. + *

+ *

+ * NB: SciJava Common called this {@code ShadowMenu}, which also implemented + * {@code Collection}, {@code Runnable} and {@code Comparable} and + * carried a context. Here it is a tree and nothing else; running a leaf is the + * caller's business, through {@code Runner}. + *

+ * + * @author Curtis Rueden + */ +public class MenuTree { + + /** The separator between elements of a menu path. */ + public static final String SEPARATOR = ">"; + + private final String label; + private final CommandInfo command; + private final Map children = new LinkedHashMap<>(); + + private MenuTree(final String label, final CommandInfo command) { + this.label = label; + this.command = command; + } + + /** + * Builds a menu tree from the given commands. + *

+ * Anything with no menu path, and anything marked not visible, is left out: + * it stays perfectly runnable, it simply does not appear here. Something + * invoked by name, or reached through a search bar, is an ordinary thing to + * have. + *

+ * + * @param commands the entries to arrange: commands, scripts, or whatever + * else an application has found + * @return the root of the tree, whose children are the top-level menus + */ + public static MenuTree of(final Collection commands) { + final MenuTree root = new MenuTree("", null); + for (final CommandInfo command : commands) { + if (!command.isVisible()) continue; + final Optional path = command.menuPath(); + if (path.isEmpty()) continue; + root.add(command, path.get().split(SEPARATOR)); + } + root.sort(); + return root; + } + + /** Gets this entry's label. Empty for the root. */ + public String label() { + return label; + } + + /** Gets what this entry runs, if it is a leaf. */ + public Optional entry() { + return Optional.ofNullable(command); + } + + /** Gets whether this entry runs a command, rather than holding others. */ + public boolean isLeaf() { + return command != null; + } + + /** Gets the entries directly beneath this one, in display order. */ + public List children() { + return List.copyOf(children.values()); + } + + /** Gets the child with the given label, if there is one. */ + public Optional child(final String label) { + return Optional.ofNullable(children.get(label)); + } + + /** + * Gets the entry at the given path, if there is one. + * + * @param path a {@code >}-separated path, as {@link Menu#path()} uses + */ + public Optional find(final String path) { + MenuTree current = this; + for (final String element : path.split(SEPARATOR)) { + final Optional child = current.child(element.trim()); + if (child.isEmpty()) return Optional.empty(); + current = child.get(); + } + return Optional.of(current); + } + + /** Gets every command in this subtree, in display order. */ + public List leaves() { + final List leaves = new ArrayList<>(); + collectLeaves(leaves); + return leaves; + } + + @Override + public String toString() { + return label + (isLeaf() ? "" : children.keySet()); + } + + // -- Helper methods -- + + private void add(final CommandInfo command, final String[] path) { + add(command, path, 0); + } + + private void add(final CommandInfo command, final String[] path, + final int depth) + { + final String element = path[depth].trim(); + final boolean last = depth == path.length - 1; + if (last) { + // NB: two commands may claim one path; the first wins, so that an + // unlucky collision does not silently discard the earlier entry. + children.putIfAbsent(element, new MenuTree(element, command)); + return; + } + final MenuTree child = children.computeIfAbsent(element, // + l -> new MenuTree(l, null)); + child.add(command, path, depth + 1); + } + + /** Orders each level by weight, then by label. */ + private void sort() { + final List sorted = new ArrayList<>(children.values()); + sorted.sort(Comparator.comparingDouble(MenuTree::weight) // + .thenComparing(MenuTree::label)); + children.clear(); + for (final MenuTree child : sorted) { + children.put(child.label(), child); + child.sort(); + } + } + + /** + * Gets the weight this entry sorts by: a leaf's own, or the lightest of the + * entries beneath it. + *

+ * NB: a submenu has no weight of its own, so it takes the position of its + * most prominent child. Otherwise every submenu would sort equally and fall + * back to alphabetical order. + *

+ */ + private double weight() { + if (isLeaf()) return command.weight(); + return children.values().stream() // + .mapToDouble(MenuTree::weight) // + .min().orElse(Double.POSITIVE_INFINITY); + } + + private void collectLeaves(final List leaves) { + if (isLeaf()) { + leaves.add(command); + return; + } + for (final MenuTree child : children.values()) + child.collectLeaves(leaves); + } +} diff --git a/command3/src/main/java/org/scijava/command3/Menus.java b/command3/src/main/java/org/scijava/command3/Menus.java new file mode 100644 index 000000000..5984eb11f --- /dev/null +++ b/command3/src/main/java/org/scijava/command3/Menus.java @@ -0,0 +1,71 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +/** + * Walks a {@link MenuTree} into a toolkit's menus. + * + * @author Curtis Rueden + */ +public final class Menus { + + private Menus() { + // prevent instantiation of utility class + } + + /** + * Builds the given menu tree into the given menu bar. + * + * @param root the tree to build, as {@link MenuTree#of} returns + * @param bar the toolkit's menu bar, which is filled in + * @param creator makes the toolkit's menus and items + * @return the menu bar, for chaining + */ + public static B build(final MenuTree root, final B bar, + final MenuCreator creator) + { + for (final MenuTree child : root.children()) { + if (child.isLeaf()) creator.topItem(child, bar); + else fill(child, creator.topMenu(child, bar), creator); + } + return bar; + } + + // -- Helper methods -- + + private static void fill(final MenuTree node, final M menu, + final MenuCreator creator) + { + for (final MenuTree child : node.children()) { + if (child.isLeaf()) creator.item(child, menu); + else fill(child, creator.subMenu(child, menu), creator); + } + } +} diff --git a/command3/src/test/java/org/scijava/command3/CommandsTest.java b/command3/src/test/java/org/scijava/command3/CommandsTest.java new file mode 100644 index 000000000..2e11a6264 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/CommandsTest.java @@ -0,0 +1,221 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Context; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Runner; + +/** + * Tests {@link Commands}: discovery, the metadata a menu is built from, and + * running what was found. + * + * @author Curtis Rueden + */ +public class CommandsTest { + + @Test + public void testDiscovery() { + try (final Context context = Context.create()) { + final List names = Commands.discover(context).stream() // + .map(ClassCommandInfo::className) // + .collect(Collectors.toList()); + assertTrue(names.contains(SayHello.class.getName()), names.toString()); + assertTrue(names.contains(CountBeans.class.getName()), names.toString()); + assertTrue(names.contains(Headless.class.getName()), names.toString()); + } + } + + /** + * The point of the two-index join: a menu is built from metadata alone, + * with no command class loaded. + */ + @Test + public void testMenuMetadataWithoutLoadingCommands() { + try (final Context context = Context.create()) { + final ClassCommandInfo hello = find(context, SayHello.class); + + assertEquals("Help>Say Hello...", hello.menuPath().orElse(null)); + assertEquals("Say Hello...", hello.label()); + assertEquals("^H", hello.accelerator().orElse(null)); + assertEquals("/icons/hello.png", hello.iconPath().orElse(null)); + assertEquals(12.0, hello.weight()); + assertTrue(hello.isVisible()); + + // NB: asserted on a command no test runs. A flag on SayHello would + // prove nothing, since another test runs it and JUnit does not order + // methods by declaration. + final ClassCommandInfo never = find(context, NeverRun.class); + assertEquals("Help>Never Run", never.menuPath().orElse(null)); + assertFalse(constructed("org.scijava.command3.NeverRun"), + "building a menu must not construct commands"); + } + } + + /** Menu order is by weight: a whole menu sorts without loading anything. */ + @Test + public void testMenuOrdering() { + try (final Context context = Context.create()) { + // NB: restricted to the commands this test is about. Asserting the + // whole list would break whenever another test adds a fixture, which + // is exactly what happened when the menu tree tests arrived. + final List ofInterest = List.of("Count Beans", "Say Hello...", + "Never Run"); + final List labels = Commands.discover(context).stream() // + .map(ClassCommandInfo::label) // + .filter(ofInterest::contains) // + .collect(Collectors.toList()); + // Weights: CountBeans 3, SayHello 12, NeverRun 50. + assertEquals(ofInterest, labels); + } + } + + /** A command needs no menu entry; it is simply not shown. */ + @Test + public void testCommandWithoutAMenuEntry() { + try (final Context context = Context.create()) { + final ClassCommandInfo headless = find(context, Headless.class); + assertTrue(headless.menuPath().isEmpty()); + // Its label falls back to something usable rather than being null. + assertEquals(Headless.class.getName(), headless.label()); + } + } + + /** What was discovered can then be run, through the ordinary runner. */ + @Test + public void testRunADiscoveredCommand() throws Exception { + try (final Context context = Context.create()) { + final ClassCommandInfo hello = find(context, SayHello.class); + final Runner runner = Runner.of(List.of(), List.of()); + + final ExecutionResult result = runner.run(hello, Map.of("name", "ada")) + .get(5, TimeUnit.SECONDS); + + assertTrue(result.isCompleted()); + assertEquals(Map.of("greeting", "hello ada"), result.outputs()); + // Only now is the class loaded and constructed. + assertTrue(constructed("org.scijava.command3.SayHello")); + } + } + + /** Each run gets its own instance, so concurrent runs cannot collide. */ + @Test + public void testEachRunHasItsOwnInstance() throws Exception { + try (final Context context = Context.create()) { + final ClassCommandInfo beans = find(context, CountBeans.class); + final Runner runner = Runner.of(List.of(), List.of()); + + final ExecutionResult first = runner.run(beans, Map.of("beans", 3)).get(5, + TimeUnit.SECONDS); + final ExecutionResult second = runner.run(beans, Map.of("beans", 7)).get( + 5, TimeUnit.SECONDS); + + assertEquals(Map.of("tally", "3 beans"), first.outputs()); + assertEquals(Map.of("tally", "7 beans"), second.outputs()); + } + } + + /** + * A command author writes one {@code opens}, to the container. + *

+ * The execution layer reads {@code @Parameter} fields through a lookup the + * context supplies, so it needs no access of its own. Were that to regress, + * running a command would fail - but this asserts the intent directly, so + * the reason is obvious rather than inferred from a stack trace. + *

+ */ + @Test + public void testOneOpensIsEnough() { + final Module module = Command.class.getModule(); + assertTrue(module.isNamed(), "not running on the module path"); + final Module context = Context.class.getModule(); + final Module execute = org.scijava.execute.Executables.class.getModule(); + + assertTrue(module.isOpen("org.scijava.command3", context), + "commands must be open to the container"); + assertFalse(module.isOpen("org.scijava.command3", execute), + "commands should not need opening to the execution layer as well"); + } + + private static ClassCommandInfo find(final Context context, final Class type) { + return Commands.discover(context).stream() // + .filter(c -> type.getName().equals(c.className())) // + .findFirst().orElseThrow(); + } + + /** + * NB: read reflectively. Referring to the class directly would load it, + * which is the very thing under test. + */ + private static boolean constructed(final String className) { + try { + return Class.forName(className).getField("constructed").getBoolean(null); + } + catch (final ReflectiveOperationException exc) { + throw new AssertionError(exc); + } + } + + /** + * Something runnable need not be in a menu, and is no less runnable for it. + *

+ * NB: this is why the type is {@code CommandInfo} rather than + * {@code MenuEntry}. A command reached by name, by a search bar or by + * another command is an ordinary thing to have; the menu tree simply takes + * the subset with a path. + *

+ */ + @Test + public void testRunnableWithoutAMenuPath() { + try (final Context context = Context.create()) { + final CommandInfo hidden = Commands.discover(context).stream() // + .filter(c -> c.className().equals(Headless.class.getName())) // + .findFirst().orElseThrow(); + + assertTrue(hidden.menuPath().isEmpty(), "declares no menu path"); + assertFalse(hidden.label().isEmpty(), "but still has a label"); + assertNotNull(hidden.create(), "and is still runnable"); + + assertTrue(MenuTree.of(List.of(hidden)).children().isEmpty(), + "so the menu tree leaves it out"); + } + } +} diff --git a/command3/src/test/java/org/scijava/command3/CountBeans.java b/command3/src/test/java/org/scijava/command3/CountBeans.java new file mode 100644 index 000000000..4cdb7bb42 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/CountBeans.java @@ -0,0 +1,51 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; +import org.scijava.struct.ItemIO; + +/** A command that appears earlier in the menu than {@link SayHello}. */ +@Plugin(type = Command.class) +@Menu(path = "Analyze>Count Beans", weight = 3) +public class CountBeans implements Command { + + @Parameter + private int beans; + + @Parameter(io = ItemIO.OUTPUT) + private String tally; + + @Override + public void run() { + tally = beans + " beans"; + } +} diff --git a/command3/src/test/java/org/scijava/command3/DeepCommand.java b/command3/src/test/java/org/scijava/command3/DeepCommand.java new file mode 100644 index 000000000..86d62a495 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/DeepCommand.java @@ -0,0 +1,47 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; + +/** A command nested several menus deep. */ +@Plugin(type = Command.class) +@Menu(path = "Image>Adjust>Brightness/Contrast...", weight = 5) +public class DeepCommand implements Command { + + @Parameter + private int level; + + @Override + public void run() { + // NB: nothing to do. + } +} diff --git a/command3/src/test/java/org/scijava/command3/Headless.java b/command3/src/test/java/org/scijava/command3/Headless.java new file mode 100644 index 000000000..7648d3714 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/Headless.java @@ -0,0 +1,50 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; +import org.scijava.struct.ItemIO; + +/** A command with no menu entry at all: runnable, but never shown. */ +@Plugin(type = Command.class) +public class Headless implements Command { + + @Parameter + private int value; + + @Parameter(io = ItemIO.OUTPUT) + private int doubled; + + @Override + public void run() { + doubled = value * 2; + } +} diff --git a/command3/src/test/java/org/scijava/command3/HiddenCommand.java b/command3/src/test/java/org/scijava/command3/HiddenCommand.java new file mode 100644 index 000000000..ecbb5db08 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/HiddenCommand.java @@ -0,0 +1,43 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import org.scijava.context.Plugin; + +/** A command with a menu path that asks not to be shown. */ +@Plugin(type = Command.class) +@Menu(path = "Help>Should Not Appear", visible = false) +public class HiddenCommand implements Command { + + @Override + public void run() { + // NB: nothing to do. + } +} diff --git a/command3/src/test/java/org/scijava/command3/MenuTreeTest.java b/command3/src/test/java/org/scijava/command3/MenuTreeTest.java new file mode 100644 index 000000000..a15f29262 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/MenuTreeTest.java @@ -0,0 +1,141 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Context; + +/** + * Tests {@link MenuTree}. + * + * @author Curtis Rueden + */ +public class MenuTreeTest { + + @Test + public void testTreeStructure() { + try (final Context context = Context.create()) { + final MenuTree root = MenuTree.of(Commands.discover(context)); + + // Top-level menus. + final List menus = labels(root); + assertTrue(menus.contains("Image"), menus.toString()); + assertTrue(menus.contains("Help"), menus.toString()); + assertTrue(menus.contains("Analyze"), menus.toString()); + + // A nested path becomes nested entries. + final MenuTree adjust = root.find("Image>Adjust").orElseThrow(); + assertFalse(adjust.isLeaf()); + assertEquals(List.of("Brightness/Contrast..."), labels(adjust)); + + final MenuTree leaf = root.find("Image>Adjust>Brightness/Contrast...") + .orElseThrow(); + assertTrue(leaf.isLeaf()); + assertEquals(DeepCommand.class.getName(), leaf.entry().orElseThrow() + .name()); + } + } + + /** Entries sort by weight; a submenu takes the weight of its lightest child. */ + @Test + public void testOrdering() { + try (final Context context = Context.create()) { + final MenuTree root = MenuTree.of(Commands.discover(context)); + final List menus = labels(root); + + // Analyze holds CountBeans (3), Image holds DeepCommand (5), and + // Help's lightest is SayHello (12). + assertEquals(List.of("Analyze", "Image", "Help"), menus); + + // Within Help: Say Hello... (12) before Never Run (50). + assertEquals(List.of("Say Hello...", "Never Run"), labels(root.find( + "Help").orElseThrow())); + } + } + + /** A command asking not to be shown is absent, though still runnable. */ + @Test + public void testInvisibleCommandIsNotInTheTree() { + try (final Context context = Context.create()) { + final MenuTree root = MenuTree.of(Commands.discover(context)); + assertTrue(root.find("Help>Should Not Appear").isEmpty()); + + // ...but it was discovered, and could be run directly. + assertTrue(Commands.discover(context).stream() // + .anyMatch(c -> HiddenCommand.class.getName().equals(c.className()))); + } + } + + /** A command with no menu path simply does not appear. */ + @Test + public void testCommandWithoutMenuPathIsNotInTheTree() { + try (final Context context = Context.create()) { + final MenuTree root = MenuTree.of(Commands.discover(context)); + final List classNames = root.leaves().stream() // + .map(CommandInfo::name) // + .collect(Collectors.toList()); + assertFalse(classNames.contains(Headless.class.getName())); + } + } + + /** Building the whole tree loads no command classes. */ + @Test + public void testTreeIsBuiltFromMetadataAlone() { + try (final Context context = Context.create()) { + final MenuTree root = MenuTree.of(Commands.discover(context)); + final MenuTree never = root.find("Help>Never Run").orElseThrow(); + assertEquals("Never Run", never.label()); + assertFalse(constructed("org.scijava.command3.NeverRun"), + "building a menu tree must not construct commands"); + } + } + + private static List labels(final MenuTree tree) { + return tree.children().stream() // + .map(MenuTree::label) // + .collect(Collectors.toList()); + } + + private static boolean constructed(final String className) { + try { + return Class.forName(className).getField("constructed").getBoolean(null); + } + catch (final ReflectiveOperationException exc) { + throw new AssertionError(exc); + } + } +} diff --git a/command3/src/test/java/org/scijava/command3/NeverRun.java b/command3/src/test/java/org/scijava/command3/NeverRun.java new file mode 100644 index 000000000..3c6856ccd --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/NeverRun.java @@ -0,0 +1,61 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; + +/** + * A command that no test runs, so that its "was I constructed?" flag means + * something. + *

+ * NB: static flags are shared across a test class, and JUnit does not run + * methods in declaration order, so a flag on a command that some other test + * runs would prove nothing. + *

+ */ +@Plugin(type = Command.class) +@Menu(path = "Help>Never Run", weight = 50) +public class NeverRun implements Command { + + public static volatile boolean constructed = false; + + public NeverRun() { + constructed = true; + } + + @Parameter + private String unused; + + @Override + public void run() { + // NB: never called. + } +} diff --git a/command3/src/test/java/org/scijava/command3/SayHello.java b/command3/src/test/java/org/scijava/command3/SayHello.java new file mode 100644 index 000000000..2954189a8 --- /dev/null +++ b/command3/src/test/java/org/scijava/command3/SayHello.java @@ -0,0 +1,59 @@ +/* + * #%L + * Discoverable commands, with the metadata a menu is built from. + * %% + * Copyright (C) 2026 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.command3; + +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; +import org.scijava.struct.ItemIO; + +/** A command with a menu entry. */ +@Plugin(type = Command.class) +@Menu(path = "Help>Say Hello...", weight = 12, accelerator = "^H", + iconPath = "/icons/hello.png") +public class SayHello implements Command { + + /** Records whether this class has ever been constructed. */ + public static volatile boolean constructed = false; + + public SayHello() { + constructed = true; + } + + @Parameter + private String name; + + @Parameter(io = ItemIO.OUTPUT) + private String greeting; + + @Override + public void run() { + greeting = "hello " + name; + } +} diff --git a/scijava-common3/.gitignore b/common3/.gitignore similarity index 100% rename from scijava-common3/.gitignore rename to common3/.gitignore diff --git a/scijava-common3/LICENSE.txt b/common3/LICENSE.txt similarity index 100% rename from scijava-common3/LICENSE.txt rename to common3/LICENSE.txt diff --git a/scijava-common3/README.md b/common3/README.md similarity index 100% rename from scijava-common3/README.md rename to common3/README.md diff --git a/scijava-common3/pom.xml b/common3/pom.xml similarity index 100% rename from scijava-common3/pom.xml rename to common3/pom.xml diff --git a/scijava-common3/src/main/java/module-info.java b/common3/src/main/java/module-info.java similarity index 100% rename from scijava-common3/src/main/java/module-info.java rename to common3/src/main/java/module-info.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Annotations.java b/common3/src/main/java/org/scijava/common3/Annotations.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Annotations.java rename to common3/src/main/java/org/scijava/common3/Annotations.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Any.java b/common3/src/main/java/org/scijava/common3/Any.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Any.java rename to common3/src/main/java/org/scijava/common3/Any.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Apps.java b/common3/src/main/java/org/scijava/common3/Apps.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Apps.java rename to common3/src/main/java/org/scijava/common3/Apps.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Classes.java b/common3/src/main/java/org/scijava/common3/Classes.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Classes.java rename to common3/src/main/java/org/scijava/common3/Classes.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Comparisons.java b/common3/src/main/java/org/scijava/common3/Comparisons.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Comparisons.java rename to common3/src/main/java/org/scijava/common3/Comparisons.java diff --git a/common3/src/main/java/org/scijava/common3/Digests.java b/common3/src/main/java/org/scijava/common3/Digests.java new file mode 100644 index 000000000..9d389f4d1 --- /dev/null +++ b/common3/src/main/java/org/scijava/common3/Digests.java @@ -0,0 +1,163 @@ +/* + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Useful methods for computing digests, and for rendering bytes as text. + * + * @author Curtis Rueden + * @author Johannes Schindelin + */ +public final class Digests { + + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Digests() { + // NB: prevent instantiation of utility class. + } + + /** Converts the given bytes to a string, assuming UTF-8 encoding. */ + public static String string(final byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + /** Converts the given string to bytes, using UTF-8 encoding. */ + public static byte[] bytes(final String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + /** Converts the given int to bytes, most significant byte first. */ + public static byte[] bytes(final int i) { + return new byte[] { // + (byte) (0xff & (i >>> 24)), // + (byte) (0xff & (i >>> 16)), // + (byte) (0xff & (i >>> 8)), // + (byte) (0xff & i) // + }; + } + + /** Renders the given bytes as a lowercase hexadecimal string. */ + public static String hex(final byte[] bytes) { + final char[] buffer = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + buffer[i * 2] = HEX[(bytes[i] & 0xf0) >> 4]; + buffer[i * 2 + 1] = HEX[bytes[i] & 0xf]; + } + return new String(buffer); + } + + /** Renders the given bytes as a base64 string. */ + public static String base64(final byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + /** Computes a weak but deterministic hash of the given string. */ + public static byte[] hash(final String s) { + return bytes(s.hashCode()); + } + + /** + * Computes a weak but deterministic hash of the given bytes. + *

+ * NB: the hash code of the byte array cannot be used directly, because + * primitive arrays inherit {@link Object#hashCode}, which differs per + * instance. Converting to a string first yields a consistent result. + *

+ */ + public static byte[] hash(final byte[] bytes) { + return hash(string(bytes)); + } + + /** Computes the SHA-1 digest of the given bytes. */ + public static byte[] sha1(final byte[] bytes) { + return digest("SHA-1", bytes); + } + + /** Computes the MD5 digest of the given bytes. */ + public static byte[] md5(final byte[] bytes) { + return digest("MD5", bytes); + } + + /** + * Computes the digest of the given bytes using the given algorithm. + * + * @param algorithm the digest algorithm; every Java platform is required to + * support {@code MD5}, {@code SHA-1} and {@code SHA-256} + * @param bytes the bytes to digest + * @return the digest + * @throws IllegalArgumentException if the algorithm is not available + */ + public static byte[] digest(final String algorithm, final byte[] bytes) { + try { + final MessageDigest digest = MessageDigest.getInstance(algorithm); + digest.update(bytes); + return digest.digest(); + } + catch (final NoSuchAlgorithmException exc) { + throw new IllegalArgumentException("No such digest algorithm: " + + algorithm, exc); + } + } + + /** Computes the strongest available digest of the given string. */ + public static byte[] best(final String s) { + return best(bytes(s)); + } + + /** Computes the strongest available digest of the given bytes. */ + public static byte[] best(final byte[] bytes) { + return sha1(bytes); + } + + /** Computes {@link #best(String)}, rendered as hexadecimal. */ + public static String bestHex(final String text) { + return hex(best(text)); + } + + /** Computes {@link #best(byte[])}, rendered as hexadecimal. */ + public static String bestHex(final byte[] bytes) { + return hex(best(bytes)); + } + + /** Computes {@link #best(String)}, rendered as base64. */ + public static String bestBase64(final String text) { + return base64(best(text)); + } + + /** Computes {@link #best(byte[])}, rendered as base64. */ + public static String bestBase64(final byte[] bytes) { + return base64(best(bytes)); + } +} diff --git a/common3/src/main/java/org/scijava/common3/FilePaths.java b/common3/src/main/java/org/scijava/common3/FilePaths.java new file mode 100644 index 000000000..5996ce053 --- /dev/null +++ b/common3/src/main/java/org/scijava/common3/FilePaths.java @@ -0,0 +1,188 @@ +/* + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Useful methods for working with file names and paths. + *

+ * Most of SciJava Common's {@code FileUtils} is not here, because + * {@link java.nio.file.Files} and {@link java.nio.file.Path} now cover it: + * reading and writing a file, temporary directories, and modification times. + * What remains is the filename logic the standard library has no answer for - + * notably versioned JAR names - plus recursive deletion, which + * {@link java.nio.file.Files} still lacks. + *

+ * + * @author Johannes Schindelin + * @author Curtis Rueden + */ +public final class FilePaths { + + private static final Pattern VERSION_PATTERN = buildVersionPattern(); + + private FilePaths() { + // NB: prevent instantiation of utility class. + } + + /** + * Gets the file's absolute path with separators normalized to {@code /}, so + * that paths compare equal across platforms. + */ + public static String normalizeSeparators(final File file) { + return normalizeSeparators(file.getAbsolutePath(), File.separator); + } + + /** Replaces the given separator with {@code /} throughout the path. */ + public static String normalizeSeparators(final String path, + final String separator) + { + return path.replaceAll(Pattern.quote(separator), "/"); + } + + /** + * Gets the file's extension: the part after the last dot of its name, with + * no leading dot, or the empty string if it has none. + */ + public static String extension(final File file) { + final String name = file.getName(); + final int dot = name.lastIndexOf('.'); + return dot < 0 ? "" : name.substring(dot + 1); + } + + /** Gets the extension of the file at the given path. */ + public static String extension(final String path) { + return extension(new File(path)); + } + + /** + * Strips the version from a versioned file name - for example, + * {@code foo-1.2.3.jar} becomes {@code foo.jar}. + * + * @param filename the file name + * @return the file name without its version, or the file name unchanged if + * it is not versioned + */ + public static String stripVersion(final String filename) { + final Matcher matcher = VERSION_PATTERN.matcher(filename); + if (!matcher.matches()) return filename; + return matcher.group(1) + matcher.group(5); + } + + /** + * Finds every version of the given file in the given directory - for + * example, given {@code foo-1.2.3.jar}, finds {@code foo-1.2.4.jar} too. + * Only versions with a matching classifier are returned. + * + * @param directory the directory to search + * @param filename the versioned file name + * @return the matching files, or {@code null} if the directory cannot be + * listed, or the file name is unversioned and does not exist + */ + public static File[] allVersions(final File directory, + final String filename) + { + final Matcher matcher = VERSION_PATTERN.matcher(filename); + if (!matcher.matches()) { + final File file = new File(directory, filename); + return file.exists() ? new File[] { file } : null; + } + final String baseName = matcher.group(1); + final String classifier = matcher.group(6); + return directory.listFiles((dir, name) -> { + if (!name.startsWith(baseName)) return false; + final Matcher m = VERSION_PATTERN.matcher(name); + if (!m.matches() || !baseName.equals(m.group(1))) return false; + final String c = m.group(6); + return classifier == null ? c == null : classifier.equals(c); + }); + } + + /** + * Matches the given file name against the versioned file name pattern, so + * that callers can pick apart its base name, version and classifier. + */ + public static Matcher matchVersionedFilename(final String filename) { + return VERSION_PATTERN.matcher(filename); + } + + /** + * Deletes the given directory and everything in it. + * + * @param directory the directory to delete; {@code null} is a no-op + * @return true if the directory is gone afterward + */ + public static boolean deleteRecursively(final File directory) { + if (directory == null) return true; + final Path path = directory.toPath(); + if (!Files.exists(path)) return true; + try (final Stream paths = Files.walk(path)) { + // NB: deepest first, so that each directory is empty when deleted. + return paths.sorted(Comparator.reverseOrder()) // + .map(Path::toFile) // + .allMatch(File::delete); + } + catch (final IOException exc) { + return false; + } + } + + private static Pattern buildVersionPattern() { + final String version = + "\\d+(\\.\\d+|\\d{7})+[a-z]?\\d?(-[A-Za-z0-9.]+?|\\.GA)*?"; + final String suffix = "\\.jar(-[a-z]*)?"; + return Pattern.compile("(.+?)(-" + version + ")?((-(" + classifiers() + + "))?(" + suffix + "))"); + } + + /** Helper method of {@link #buildVersionPattern()}. */ + private static String classifiers() { + final String[] classifiers = { // + "swing", // + "swt", // + "shaded", // + "sources", // + "javadoc", // + "natives?-?\\w*", // + "(natives-)?(android|linux|macosx|macos|solaris|windows)-" + + "(aarch64|amd64|arm64|armv6hf|armv6|arm|" + + "i386|i486|i586|i686|universal|x86[_-]32|x86[_-]64|x86)" // + }; + return "(" + String.join("|", classifiers) + ")"; + } +} diff --git a/scijava-common3/src/main/java/org/scijava/common3/MersenneTwisterFast.java b/common3/src/main/java/org/scijava/common3/MersenneTwisterFast.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/MersenneTwisterFast.java rename to common3/src/main/java/org/scijava/common3/MersenneTwisterFast.java diff --git a/common3/src/main/java/org/scijava/common3/Numbers.java b/common3/src/main/java/org/scijava/common3/Numbers.java new file mode 100644 index 000000000..f152645c5 --- /dev/null +++ b/common3/src/main/java/org/scijava/common3/Numbers.java @@ -0,0 +1,198 @@ +/* + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import java.math.BigDecimal; +import java.math.BigInteger; + +/** + * Useful methods for working with {@link Number}s. + * + * @author Curtis Rueden + */ +public final class Numbers { + + private Numbers() { + // NB: prevent instantiation of utility class. + } + + /** + * Converts the given value to a {@link Number} of the given type. + *

+ * Numbers are narrowed or widened as needed, and strings are parsed. Note + * that unlike SciJava Common's {@code NumberUtils.toNumber}, no general + * conversion framework is consulted: this method handles numbers and + * strings only, so that this module remains dependency-free. + *

+ * + * @param value the value to convert + * @param type the desired numeric type + * @return the converted number, or {@code null} if the value is + * {@code null}, or cannot be interpreted as a number of that type + */ + public static Number toNumber(final Object value, final Class type) { + if (value == null) return null; + final Number n; + if (value instanceof Number) n = (Number) value; + else { + final String s = value.toString().trim(); + if (s.isEmpty()) return null; + try { + n = new BigDecimal(s); + } + catch (final NumberFormatException exc) { + return null; + } + } + if (Classes.isByte(type)) return n.byteValue(); + if (Classes.isShort(type)) return n.shortValue(); + if (Classes.isInteger(type)) return n.intValue(); + if (Classes.isLong(type)) return n.longValue(); + if (Classes.isFloat(type)) return n.floatValue(); + if (Classes.isDouble(type)) return n.doubleValue(); + if (BigInteger.class.isAssignableFrom(type)) return bigInteger(n); + if (BigDecimal.class.isAssignableFrom(type)) return bigDecimal(n); + if (Classes.isNumber(type)) return n; + return null; + } + + /** + * Converts the given number to a {@link BigDecimal}, without the loss of + * accuracy that routing a long or {@link BigInteger} through + * {@link Number#doubleValue} would cause. + */ + public static BigDecimal bigDecimal(final Number n) { + if (n instanceof BigDecimal) return (BigDecimal) n; + if (n instanceof BigInteger) return new BigDecimal((BigInteger) n); + if (n instanceof Long || n instanceof Integer || n instanceof Short || + n instanceof Byte) + { + return BigDecimal.valueOf(n.longValue()); + } + return BigDecimal.valueOf(n.doubleValue()); + } + + /** Converts the given number to a {@link BigInteger}. */ + public static BigInteger bigInteger(final Number n) { + if (n instanceof BigInteger) return (BigInteger) n; + return BigInteger.valueOf(n.longValue()); + } + + /** + * Gets the smallest value of the given numeric type. + * + * @return the minimum, or {@code null} if the type is not numeric + */ + public static Number minimum(final Class type) { + if (Classes.isByte(type)) return Byte.MIN_VALUE; + if (Classes.isShort(type)) return Short.MIN_VALUE; + if (Classes.isInteger(type)) return Integer.MIN_VALUE; + if (Classes.isLong(type)) return Long.MIN_VALUE; + if (Classes.isFloat(type)) return -Float.MAX_VALUE; + if (Classes.isDouble(type)) return -Double.MAX_VALUE; + // NB: fallback for Number.class itself. + if (Classes.isNumber(type)) return -Double.MAX_VALUE; + return null; + } + + /** + * Gets the largest value of the given numeric type. + * + * @return the maximum, or {@code null} if the type is not numeric + */ + public static Number maximum(final Class type) { + if (Classes.isByte(type)) return Byte.MAX_VALUE; + if (Classes.isShort(type)) return Short.MAX_VALUE; + if (Classes.isInteger(type)) return Integer.MAX_VALUE; + if (Classes.isLong(type)) return Long.MAX_VALUE; + if (Classes.isFloat(type)) return Float.MAX_VALUE; + if (Classes.isDouble(type)) return Double.MAX_VALUE; + // NB: fallback for Number.class itself. + if (Classes.isNumber(type)) return Double.MAX_VALUE; + return null; + } + + /** + * Gets a sensible default value for the given numeric type and range: the + * minimum if there is one, else the maximum, else zero. + */ + public static Number defaultValue(final Number min, final Number max, + final Class type) + { + if (min != null) return min; + if (max != null) return max; + return toNumber("0", type); + } + + /** + * Clamps the given value to the given range. + * + * @return the value, or the bound it exceeds; or + * {@link #defaultValue(Number, Number, Class)} if the value is + * {@code null} + */ + public static Number clamp(final Class type, final Number value, + final Number min, final Number max) + { + if (value == null) return defaultValue(min, max, type); + if (Comparable.class.isAssignableFrom(type)) { + @SuppressWarnings("unchecked") + final Comparable cValue = (Comparable) value; + if (min != null && cValue.compareTo(min) < 0) return min; + if (max != null && cValue.compareTo(max) > 0) return max; + } + return value; + } + + /** + * Multiplies the given values, throwing rather than overflowing. + * + * @param values the values to multiply + * @return the product + * @throws ArithmeticException if the product overflows an {@code int} + */ + public static int safeMultiply32(final long... values) { + return Math.toIntExact(safeMultiply64(values)); + } + + /** + * Multiplies the given values, throwing rather than overflowing. + * + * @param values the values to multiply + * @return the product + * @throws ArithmeticException if the product overflows a {@code long} + */ + public static long safeMultiply64(final long... values) { + long product = 1; + for (final long value : values) + product = Math.multiplyExact(product, value); + return product; + } +} diff --git a/scijava-common3/src/main/java/org/scijava/common3/Platforms.java b/common3/src/main/java/org/scijava/common3/Platforms.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Platforms.java rename to common3/src/main/java/org/scijava/common3/Platforms.java diff --git a/common3/src/main/java/org/scijava/common3/Processes.java b/common3/src/main/java/org/scijava/common3/Processes.java new file mode 100644 index 000000000..454a1b750 --- /dev/null +++ b/common3/src/main/java/org/scijava/common3/Processes.java @@ -0,0 +1,184 @@ +/* + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.util.Arrays; + +/** + * Useful methods for launching external processes. + * + * @author Johannes Schindelin + * @author Curtis Rueden + */ +public final class Processes { + + private Processes() { + // NB: prevent instantiation of utility class. + } + + /** + * Executes the given command, blocking until it completes. + * + * @param workingDirectory the working directory, or {@code null} to inherit + * @param err where to echo the process's standard error, or {@code null} + * @param out where to echo the process's standard output, or {@code null} + * @param args the command and its arguments + * @return everything the process wrote to standard output + * @throws RuntimeException if the process fails, or the thread is + * interrupted while waiting for it + */ + public static String exec(final File workingDirectory, + final PrintStream err, final PrintStream out, final String... args) + { + return exec(workingDirectory, null, err, out, args); + } + + /** + * Executes the given command, blocking until it completes. + * + * @param workingDirectory the working directory, or {@code null} to inherit + * @param in fed to the process's standard input, or {@code null} for none + * @param err where to echo the process's standard error, or {@code null} + * @param out where to echo the process's standard output, or {@code null} + * @param args the command and its arguments + * @return everything the process wrote to standard output + * @throws RuntimeException if the process fails, or the thread is + * interrupted while waiting for it + */ + public static String exec(final File workingDirectory, final InputStream in, + final PrintStream err, final PrintStream out, final String... args) + { + final Process process; + try { + process = new ProcessBuilder(args).directory(workingDirectory).start(); + } + catch (final IOException exc) { + throw new RuntimeException(exc); + } + + // NB: the process's stdin must be closed once the input is exhausted, + // or the process will wait forever for more. The caller's streams, by + // contrast, belong to the caller and must be left open. + final Pump inPump = in == null ? null : // + new Pump(in, process.getOutputStream(), true); + final Pump errPump = new Pump(process.getErrorStream(), err, false); + final Pump outPump = new Pump(process.getInputStream(), out, false); + if (inPump != null) inPump.start(); + errPump.start(); + outPump.start(); + + try { + process.waitFor(); + if (inPump != null) inPump.join(); + errPump.join(); + outPump.join(); + } + catch (final InterruptedException exc) { + process.destroy(); + if (inPump != null) inPump.interrupt(); + errPump.interrupt(); + outPump.interrupt(); + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while awaiting " + // + Arrays.toString(args), exc); + } + + if (process.exitValue() != 0) { + throw new RuntimeException("exit status " + process.exitValue() + ": " + + Arrays.toString(args) + "\n" + errPump); + } + return outPump.toString(); + } + + /** + * Copies one stream into another, accumulating what passes through so that + * it can be returned to the caller afterward. + */ + private static class Pump extends Thread { + + private final InputStream input; + private final OutputStream output; + private final boolean closeOutput; + private final ByteArrayOutputStream accumulated = + new ByteArrayOutputStream(); + + Pump(final InputStream input, final OutputStream output, + final boolean closeOutput) + { + setDaemon(true); + this.input = input; + this.output = output; + this.closeOutput = closeOutput; + } + + @Override + public void run() { + final byte[] buffer = new byte[8192]; + try { + while (true) { + final int count = input.read(buffer); + if (count < 0) break; + accumulated.write(buffer, 0, count); + if (output != null) { + output.write(buffer, 0, count); + output.flush(); + } + } + } + catch (final IOException exc) { + // NB: the process died, or the stream was closed beneath us. + } + finally { + if (closeOutput && output != null) { + try { + output.close(); + } + catch (final IOException exc) { + // NB: nothing we can do about it here. + } + } + } + } + + @Override + public String toString() { + // NB: decode once, at the end: a multi-byte character can straddle + // two reads, and decoding each chunk separately would corrupt it. + return new String(accumulated.toByteArray(), Charset.defaultCharset()); + } + } +} diff --git a/common3/src/main/java/org/scijava/common3/Strings.java b/common3/src/main/java/org/scijava/common3/Strings.java new file mode 100644 index 000000000..38a7eba4d --- /dev/null +++ b/common3/src/main/java/org/scijava/common3/Strings.java @@ -0,0 +1,119 @@ +/* + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import java.util.regex.Pattern; + +/** + * Useful methods for working with {@link String}s. + * + * @author Curtis Rueden + * @author Johannes Schindelin + */ +public final class Strings { + + /** The character used for padding when none is given. */ + public static final char DEFAULT_PAD_CHAR = ' '; + + private Strings() { + // NB: prevent instantiation of utility class. + } + + /** + * Splits the given string around occurrences of the separator, ignoring + * separators that fall inside double quotes. + * + * @param s the string to split + * @param separator the separator, treated as a literal, not a regex + * @return the resulting substrings, including trailing empty ones + */ + public static String[] splitUnquoted(final String s, + final String separator) + { + // See https://stackoverflow.com/a/1757107/1919049 + return s.split(Pattern.quote(separator) + + "(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1); + } + + /** Gets whether the given string is {@code null} or of length zero. */ + public static boolean isNullOrEmpty(final String s) { + return s == null || s.isEmpty(); + } + + /** + * Pads the end of the given string with {@link #DEFAULT_PAD_CHAR} until it + * reaches the given length. + */ + public static String padEnd(final String s, final int length) { + return padEnd(s, length, DEFAULT_PAD_CHAR); + } + + /** + * Pads the end of the given string with the given character until it + * reaches the given length. A string already that long is returned as is. + */ + public static String padEnd(final String s, final int length, + final char padChar) + { + return pad(s, length, padChar, false); + } + + /** + * Pads the start of the given string with {@link #DEFAULT_PAD_CHAR} until + * it reaches the given length. + */ + public static String padStart(final String s, final int length) { + return padStart(s, length, DEFAULT_PAD_CHAR); + } + + /** + * Pads the start of the given string with the given character until it + * reaches the given length. A string already that long is returned as is. + */ + public static String padStart(final String s, final int length, + final char padChar) + { + return pad(s, length, padChar, true); + } + + private static String pad(final String s, final int length, + final char padChar, final boolean atStart) + { + if (s == null) return null; + final int padding = length - s.length(); + if (padding <= 0) return s; + final StringBuilder sb = new StringBuilder(length); + if (!atStart) sb.append(s); + for (int i = 0; i < padding; i++) + sb.append(padChar); + if (atStart) sb.append(s); + return sb.toString(); + } +} diff --git a/scijava-common3/src/main/java/org/scijava/common3/Threads.java b/common3/src/main/java/org/scijava/common3/Threads.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Threads.java rename to common3/src/main/java/org/scijava/common3/Threads.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Types.java b/common3/src/main/java/org/scijava/common3/Types.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Types.java rename to common3/src/main/java/org/scijava/common3/Types.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/URLs.java b/common3/src/main/java/org/scijava/common3/URLs.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/URLs.java rename to common3/src/main/java/org/scijava/common3/URLs.java diff --git a/scijava-common3/src/test/java/org/scijava/common3/ClassesTest.java b/common3/src/test/java/org/scijava/common3/ClassesTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/ClassesTest.java rename to common3/src/test/java/org/scijava/common3/ClassesTest.java diff --git a/scijava-common3/src/test/java/org/scijava/common3/CommonSuperTypeTest.java b/common3/src/test/java/org/scijava/common3/CommonSuperTypeTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/CommonSuperTypeTest.java rename to common3/src/test/java/org/scijava/common3/CommonSuperTypeTest.java diff --git a/common3/src/test/java/org/scijava/common3/DigestsTest.java b/common3/src/test/java/org/scijava/common3/DigestsTest.java new file mode 100644 index 000000000..5718af950 --- /dev/null +++ b/common3/src/test/java/org/scijava/common3/DigestsTest.java @@ -0,0 +1,137 @@ +/*- + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Digests}. + * + * @author Curtis Rueden + */ +public class DigestsTest { + + private static final byte[] COFFEE_SHA1 = { -71, 2, 27, -126, -23, -70, -89, + 35, -65, -15, -108, 66, 72, 113, 29, -32, -12, -42, -49, 6 }; + + private static final byte[] HELLO_WORLD_SHA1 = { 123, 80, 44, 58, 31, 72, + -56, 96, -102, -30, 18, -51, -5, 99, -99, -18, 57, 103, 63, 94 }; + + private static final String HELLO_WORLD_SHA1_HEX = + "7b502c3a1f48c8609ae212cdfb639dee39673f5e"; + + private static final String COFFEE_SHA1_HEX = + "b9021b82e9baa723bff1944248711de0f4d6cf06"; + + private static final String HELLO_WORLD_SHA1_BASE64 = + "e1AsOh9IyGCa4hLN+2Od7jlnP14="; + + private static final String COFFEE_SHA1_BASE64 = + "uQIbgum6pyO/8ZRCSHEd4PTWzwY="; + + @Test + public void testBytesString() { + final byte[] expected = { 72, 101, 108, 108, 111, 32, 119, 111, 114, 108, + 100 }; + assertArrayEquals(expected, Digests.bytes("Hello world")); + } + + @Test + public void testBytesInt() { + assertArrayEquals(new byte[] { 0, -64, -1, -18 }, Digests.bytes(0xc0ffee)); + } + + @Test + public void testString() { + assertEquals("Hello world", Digests.string(Digests.bytes("Hello world"))); + } + + @Test + public void testHex() { + assertEquals("00c0ffee", Digests.hex(Digests.bytes(0xc0ffee))); + assertEquals("deadbeef", Digests.hex(Digests.bytes(0xdeadbeef))); + assertEquals("00000000", Digests.hex(Digests.bytes(0x00000000))); + assertEquals("ffffffff", Digests.hex(Digests.bytes(0xffffffff))); + } + + @Test + public void testBase64() { + assertEquals("AMD/7g==", Digests.base64(Digests.bytes(0xc0ffee))); + assertEquals("3q2+7w==", Digests.base64(Digests.bytes(0xdeadbeef))); + assertEquals("AAAAAA==", Digests.base64(Digests.bytes(0x00000000))); + assertEquals("/////w==", Digests.base64(Digests.bytes(0xffffffff))); + } + + @Test + public void testHashString() { + assertArrayEquals(new byte[] { -50, 89, -118, -92 }, Digests.hash( + "Hello world")); + } + + @Test + public void testDigest() { + final byte[] bytes = Digests.bytes(0xc0ffee); + assertArrayEquals(Digests.sha1(bytes), Digests.digest("SHA-1", bytes)); + assertArrayEquals(Digests.md5(bytes), Digests.digest("MD5", bytes)); + } + + /** + * SciJava Common returned {@code null} for an unknown algorithm, which + * surfaced as a {@link NullPointerException} somewhere else entirely. + */ + @Test + public void testDigestUnknownAlgorithm() { + assertThrows(IllegalArgumentException.class, // + () -> Digests.digest("NO-SUCH-ALGORITHM", Digests.bytes("x"))); + } + + @Test + public void testBest() { + assertArrayEquals(HELLO_WORLD_SHA1, Digests.best("Hello world")); + assertArrayEquals(COFFEE_SHA1, Digests.best(Digests.bytes(0xc0ffee))); + } + + @Test + public void testBestHex() { + assertEquals(HELLO_WORLD_SHA1_HEX, Digests.bestHex("Hello world")); + assertEquals(COFFEE_SHA1_HEX, Digests.bestHex(Digests.bytes(0xc0ffee))); + } + + @Test + public void testBestBase64() { + assertEquals(HELLO_WORLD_SHA1_BASE64, Digests.bestBase64("Hello world")); + assertEquals(COFFEE_SHA1_BASE64, Digests.bestBase64(Digests.bytes( + 0xc0ffee))); + } +} diff --git a/common3/src/test/java/org/scijava/common3/FilePathsTest.java b/common3/src/test/java/org/scijava/common3/FilePathsTest.java new file mode 100644 index 000000000..6f312b202 --- /dev/null +++ b/common3/src/test/java/org/scijava/common3/FilePathsTest.java @@ -0,0 +1,163 @@ +/*- + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FilePaths}. + * + * @author Johannes Schindelin + * @author Curtis Rueden + */ +public class FilePathsTest { + + @Test + public void testNormalizeSeparators() { + // Windows-style paths get standardized. + assertEquals("C:/path/to/my-windows-file", // + FilePaths.normalizeSeparators("C:\\path\\to\\my-windows-file", "\\")); + // *nix-style paths are unchanged. + assertEquals("/path/to/my-nix-file", // + FilePaths.normalizeSeparators("/path/to/my-nix-file", "/")); + // An already-standardized path stays good on Windows. + assertEquals("/path/to/my-nix-file", // + FilePaths.normalizeSeparators("/path/to/my-nix-file", "\\")); + } + + @Test + public void testExtension() { + assertEquals("ext", FilePaths.extension("/path/to/file.ext")); + assertEquals("", FilePaths.extension("/path/to/file")); + assertEquals("a", FilePaths.extension("/etc/init.d/xyz/file.a")); + assertEquals("", FilePaths.extension("/etc/init.d/xyz/file")); + } + + @Test + public void testStripVersion() { + assertEquals("jars/bio-formats.jar", // + FilePaths.stripVersion("jars/bio-formats-4.4-imagej-2.0.0-beta1.jar")); + assertEquals(FilePaths.stripVersion("jars/ij-data-2.0.0.1-beta1.jar"), // + FilePaths.stripVersion("jars/ij-data-2.0.0.1-SNAPSHOT.jar")); + assertEquals(FilePaths.stripVersion("jars/ij-1.44.jar"), // + FilePaths.stripVersion("jars/ij-1.46b.jar")); + assertEquals(FilePaths.stripVersion("jars/javassist.jar"), // + FilePaths.stripVersion("jars/javassist-3.9.0.GA.jar")); + assertEquals(FilePaths.stripVersion("jars/javassist.jar"), // + FilePaths.stripVersion("jars/javassist-3.16.1-GA.jar")); + assertEquals(FilePaths.stripVersion("jars/bsh.jar"), // + FilePaths.stripVersion("jars/bsh-2.0b4.jar")); + assertEquals(FilePaths.stripVersion("jars/mpicbg.jar"), // + FilePaths.stripVersion("jars/mpicbg-20111128.jar")); + assertEquals(FilePaths.stripVersion("jars/miglayout-swing.jar"), // + FilePaths.stripVersion("jars/miglayout-3.7.3.1-swing.jar")); + } + + /** Native binary JARs must not clash with their partner hook JAR. */ + @Test + public void testStripVersionNativeBinaries() { + assertEquals("jars/ffmpeg-windows-x86.jar", // + FilePaths.stripVersion("jars/ffmpeg-2.6.1-0.11-windows-x86.jar")); + assertEquals("jars/ffmpeg-windows-x86_64.jar", // + FilePaths.stripVersion("jars/ffmpeg-2.6.1-0.11-windows-x86_64.jar")); + assertEquals("jars/ffmpeg-macosx-x86_64.jar", // + FilePaths.stripVersion("jars/ffmpeg-2.6.1-0.11-macosx-x86_64.jar")); + assertEquals("jars/ffmpeg-linux-x86_64.jar", // + FilePaths.stripVersion("jars/ffmpeg-2.6.1-0.11-linux-x86_64.jar")); + assertEquals("jars/ffmpeg-android-arm.jar", // + FilePaths.stripVersion("jars/ffmpeg-2.6.1-0.11-android-arm.jar")); + } + + /** The jogamp style of native binary JARs. */ + @Test + public void testStripVersionJogamp() { + assertEquals("jars/jogl-all-natives-android-aarch64.jar", FilePaths + .stripVersion("jars/jogl-all-2.3.0-natives-android-aarch64.jar")); + assertEquals("jars/jogl-all-natives-linux-armv6hf.jar", FilePaths + .stripVersion("jars/jogl-all-2.3.0-natives-linux-armv6hf.jar")); + assertEquals("jars/jogl-all-natives-macosx-universal.jar", FilePaths + .stripVersion("jars/jogl-all-2.3.0-natives-macosx-universal.jar")); + assertEquals("jars/jogl-all-natives-windows-i586.jar", FilePaths + .stripVersion("jars/jogl-all-2.3.0-natives-windows-i586.jar")); + // The jinput style. + assertEquals("jars/jinput-natives-all.jar", // + FilePaths.stripVersion("jars/jinput-2.0.9-natives-all.jar")); + // native-lib-loader must not be mistaken for a native classifier. + assertEquals("jars/native-lib-loader.jar", // + FilePaths.stripVersion("jars/native-lib-loader-2.3.2.jar")); + } + + @Test + public void testAllVersions() throws IOException { + final String withClassifier = "miglayout-3.7.3.1-swing.jar"; + final String withoutClassifier = "miglayout-3.7.3.1.jar"; + final File tmp = Files.createTempDirectory("all-versions-").toFile(); + try { + assertTrue(new File(tmp, withClassifier).createNewFile()); + assertTrue(new File(tmp, withoutClassifier).createNewFile()); + + // A classifier matches only files with that same classifier. + assertArrayEquals(new File[] { new File(tmp, withClassifier) }, // + FilePaths.allVersions(tmp, withClassifier)); + assertArrayEquals(new File[] { new File(tmp, withoutClassifier) }, // + FilePaths.allVersions(tmp, withoutClassifier)); + } + finally { + FilePaths.deleteRecursively(tmp); + } + } + + @Test + public void testDeleteRecursively() throws IOException { + final File tmp = Files.createTempDirectory("delete-recursively-").toFile(); + final File nested = new File(new File(tmp, "a"), "b"); + assertTrue(nested.mkdirs()); + assertTrue(new File(nested, "file.txt").createNewFile()); + assertTrue(new File(tmp, "top.txt").createNewFile()); + + assertTrue(FilePaths.deleteRecursively(tmp)); + assertFalse(tmp.exists()); + } + + @Test + public void testDeleteRecursivelyNullOrMissing() { + assertTrue(FilePaths.deleteRecursively(null)); + assertTrue(FilePaths.deleteRecursively(new File("no-such-directory-xyz"))); + } +} diff --git a/common3/src/test/java/org/scijava/common3/NumbersTest.java b/common3/src/test/java/org/scijava/common3/NumbersTest.java new file mode 100644 index 000000000..cac56ae75 --- /dev/null +++ b/common3/src/test/java/org/scijava/common3/NumbersTest.java @@ -0,0 +1,136 @@ +/*- + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Numbers}. + * + * @author Curtis Rueden + */ +public class NumbersTest { + + @Test + public void testMinimum() { + assertEquals(Byte.MIN_VALUE, Numbers.minimum(Byte.class)); + assertEquals(Short.MIN_VALUE, Numbers.minimum(Short.class)); + assertEquals(Integer.MIN_VALUE, Numbers.minimum(Integer.class)); + assertEquals(Long.MIN_VALUE, Numbers.minimum(Long.class)); + assertEquals(-Float.MAX_VALUE, Numbers.minimum(Float.class)); + assertEquals(-Double.MAX_VALUE, Numbers.minimum(Double.class)); + // NB: Number's minimum is the smallest of all the above -> Double. + assertEquals(-Double.MAX_VALUE, Numbers.minimum(Number.class)); + assertNull(Numbers.minimum(String.class)); + } + + @Test + public void testMaximum() { + assertEquals(Byte.MAX_VALUE, Numbers.maximum(Byte.class)); + assertEquals(Short.MAX_VALUE, Numbers.maximum(Short.class)); + assertEquals(Integer.MAX_VALUE, Numbers.maximum(Integer.class)); + assertEquals(Long.MAX_VALUE, Numbers.maximum(Long.class)); + assertEquals(Float.MAX_VALUE, Numbers.maximum(Float.class)); + assertEquals(Double.MAX_VALUE, Numbers.maximum(Double.class)); + // NB: Number's maximum is the largest of all the above -> Double. + assertEquals(Double.MAX_VALUE, Numbers.maximum(Number.class)); + assertNull(Numbers.maximum(String.class)); + } + + @Test + public void testToNumber() { + assertEquals(5, Numbers.toNumber("5", Integer.class)); + assertEquals(5, Numbers.toNumber("5", int.class)); + assertEquals(5.5, Numbers.toNumber("5.5", Double.class)); + assertEquals((byte) 5, Numbers.toNumber(5.9, Byte.class)); + assertEquals(5L, Numbers.toNumber("5", Long.class)); + assertEquals(BigInteger.valueOf(5), Numbers.toNumber("5", + BigInteger.class)); + assertEquals(BigDecimal.valueOf(5.5), Numbers.toNumber(5.5, + BigDecimal.class)); + } + + @Test + public void testToNumberInvalid() { + assertNull(Numbers.toNumber(null, Integer.class)); + assertNull(Numbers.toNumber("", Integer.class)); + assertNull(Numbers.toNumber("not a number", Integer.class)); + assertNull(Numbers.toNumber("5", String.class)); + } + + @Test + public void testBigDecimal() { + // NB: routing a long through doubleValue() would lose accuracy. + final long big = 9007199254740993L; // 2^53 + 1 + assertEquals(BigDecimal.valueOf(big), Numbers.bigDecimal(big)); + assertEquals(new BigDecimal(BigInteger.TEN), Numbers.bigDecimal( + BigInteger.TEN)); + } + + @Test + public void testBigInteger() { + assertEquals(BigInteger.valueOf(5), Numbers.bigInteger(5)); + assertEquals(BigInteger.TEN, Numbers.bigInteger(BigInteger.TEN)); + } + + @Test + public void testDefaultValue() { + assertEquals(3, Numbers.defaultValue(3, 7, Integer.class)); + assertEquals(7, Numbers.defaultValue(null, 7, Integer.class)); + assertEquals(0, Numbers.defaultValue(null, null, Integer.class)); + } + + @Test + public void testClamp() { + assertEquals(5, Numbers.clamp(Integer.class, 5, 0, 10)); + assertEquals(0, Numbers.clamp(Integer.class, -1, 0, 10)); + assertEquals(10, Numbers.clamp(Integer.class, 11, 0, 10)); + assertEquals(0, Numbers.clamp(Integer.class, null, 0, 10)); + } + + @Test + public void testSafeMultiply() { + assertEquals(24, Numbers.safeMultiply32(2, 3, 4)); + assertEquals(1, Numbers.safeMultiply64()); + assertEquals(4611686018427387904L, Numbers.safeMultiply64(1L << 31, + 1L << 31)); + assertThrows(ArithmeticException.class, // + () -> Numbers.safeMultiply32(1L << 31, 2)); + assertThrows(ArithmeticException.class, // + () -> Numbers.safeMultiply64(1L << 62, 4)); + } +} diff --git a/common3/src/test/java/org/scijava/common3/ProcessesTest.java b/common3/src/test/java/org/scijava/common3/ProcessesTest.java new file mode 100644 index 000000000..b22942984 --- /dev/null +++ b/common3/src/test/java/org/scijava/common3/ProcessesTest.java @@ -0,0 +1,118 @@ +/*- + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Processes}. + * + * @author Johannes Schindelin + * @author Curtis Rueden + */ +public class ProcessesTest { + + @Test + public void testStdout() { + assumePOSIX(); + assertEquals("hi\n", Processes.exec(null, null, null, "echo", "hi")); + } + + /** + * Feeding a process requires closing its standard input once the input is + * exhausted; otherwise {@code cat} would wait forever for more. + */ + @Test + public void testStdin() { + assumePOSIX(); + final String value = "Hello, World!\n"; + final InputStream input = new ByteArrayInputStream(value.getBytes()); + assertEquals(value, Processes.exec(null, input, null, null, "cat")); + } + + /** A multi-byte character must survive being split across two reads. */ + @Test + public void testMultiByteOutput() { + assumePOSIX(); + final String value = "éèê"; + final InputStream input = new ByteArrayInputStream(value.getBytes()); + assertEquals(value, Processes.exec(null, input, null, null, "cat")); + } + + @Test + public void testNonZeroExitStatus() { + assumePOSIX(); + assertThrows(RuntimeException.class, // + () -> Processes.exec(null, null, null, "false")); + } + + @Test + public void testInterruptible() throws InterruptedException { + assumePOSIX(); + final SleepThread thread = new SleepThread(5); + thread.start(); + Thread.sleep(100); + thread.interrupt(); + thread.join(10000); + assertNotNull(thread.result, "exec did not return when interrupted"); + } + + private void assumePOSIX() { + assumeTrue(Platforms.isPOSIX()); + } + + private static class SleepThread extends Thread { + + private final int seconds; + private Throwable result; + + SleepThread(final int seconds) { + this.seconds = seconds; + } + + @Override + public void run() { + try { + Processes.exec(null, null, null, "sleep", "" + seconds); + } + catch (final Throwable t) { + result = t; + } + } + } +} diff --git a/common3/src/test/java/org/scijava/common3/StringsTest.java b/common3/src/test/java/org/scijava/common3/StringsTest.java new file mode 100644 index 000000000..5a7dbb4cc --- /dev/null +++ b/common3/src/test/java/org/scijava/common3/StringsTest.java @@ -0,0 +1,86 @@ +/*- + * #%L + * Common functionality widely used across SciJava modules. + * %% + * Copyright (C) 2021 - 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.common3; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Strings}. + * + * @author Curtis Rueden + */ +public class StringsTest { + + @Test + public void testPadEnd() { + assertEquals("ab ", Strings.padEnd("ab", 5)); + assertEquals("ab...", Strings.padEnd("ab", 5, '.')); + assertEquals("abcdef", Strings.padEnd("abcdef", 3)); + assertEquals("", Strings.padEnd("", 0)); + assertNull(Strings.padEnd(null, 5)); + } + + @Test + public void testPadStart() { + assertEquals(" ab", Strings.padStart("ab", 5)); + assertEquals("...ab", Strings.padStart("ab", 5, '.')); + assertEquals("abcdef", Strings.padStart("abcdef", 3)); + assertNull(Strings.padStart(null, 5)); + } + + @Test + public void testIsNullOrEmpty() { + assertTrue(Strings.isNullOrEmpty(null)); + assertTrue(Strings.isNullOrEmpty("")); + assertFalse(Strings.isNullOrEmpty(" ")); + assertFalse(Strings.isNullOrEmpty("x")); + } + + @Test + public void testSplitUnquoted() { + assertArrayEquals(new String[] { "a", "b", "c" }, // + Strings.splitUnquoted("a,b,c", ",")); + // NB: separators inside double quotes do not split. + assertArrayEquals(new String[] { "a", "\"b,c\"" }, // + Strings.splitUnquoted("a,\"b,c\"", ",")); + // NB: the separator is a literal, not a regular expression. + assertArrayEquals(new String[] { "a", "b" }, // + Strings.splitUnquoted("a.b", ".")); + // NB: trailing empty strings are retained. + assertArrayEquals(new String[] { "a", "", "" }, // + Strings.splitUnquoted("a,,", ",")); + } +} diff --git a/scijava-common3/src/test/java/org/scijava/common3/TypesTest.java b/common3/src/test/java/org/scijava/common3/TypesTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/TypesTest.java rename to common3/src/test/java/org/scijava/common3/TypesTest.java diff --git a/compat/pom.xml b/compat/pom.xml new file mode 100644 index 000000000..10af03168 --- /dev/null +++ b/compat/pom.xml @@ -0,0 +1,234 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-compat + + 1.0.0-SNAPSHOT + + SciJava Compat + Backwards compatibility with SciJava Common. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.compat + + bsd_2 + SciJava developers. + + + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + + org.scijava + scijava-common + + + org.scijava + scijava-command3 + ${scijava-command3.version} + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-convert3 + ${scijava-convert3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + + org.scijava + scijava-harvest + ${scijava-harvest.version} + test + + + org.scijava + scijava-ui3 + ${scijava-ui3.version} + test + + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-common + ${scijava-common.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/compat/src/main/java/module-info.java b/compat/src/main/java/module-info.java new file mode 100644 index 000000000..d4518acb8 --- /dev/null +++ b/compat/src/main/java/module-info.java @@ -0,0 +1,51 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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% + */ + +/** + * The one place the two worlds meet. + *

+ * NB: {@code requires org.scijava} is SciJava Common, whose jar declares that + * name itself ({@code Automatic-Module-Name: org.scijava}) - so it is stable, + * not filename-derived. That the two can sit on the module path together at + * all is what the {@code org.scijava.command3} rename bought: SciJava Common + * owns the package {@code org.scijava.command}, and two modules containing one + * package is a JVM that refuses to start. + *

+ */ +open module org.scijava.compat { + + exports org.scijava.compat; + + requires transitive org.scijava; + requires transitive org.scijava.command3; + requires transitive org.scijava.execute; + requires transitive org.scijava.struct; + requires org.scijava.common3; + +} diff --git a/compat/src/main/java/org/scijava/compat/Legacy.java b/compat/src/main/java/org/scijava/compat/Legacy.java new file mode 100644 index 000000000..df6c84282 --- /dev/null +++ b/compat/src/main/java/org/scijava/compat/Legacy.java @@ -0,0 +1,101 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import org.scijava.command3.CommandInfo; +import org.scijava.module.ModuleInfo; +import org.scijava.module.ModuleService; + +/** + * Everything SciJava Common knows how to run, as SciJava3 commands. + *

+ * This is the bridge's front door, and it is one call: + *

+ * + *
+ * final org.scijava.Context legacy = new org.scijava.Context();
+ * final List<CommandInfo> commands = Legacy.commands(legacy);
+ * 
+ *

+ * The result goes into an application's menus beside its own commands and its + * scripts, which is what makes the migration incremental: a command is ported + * when someone gets to it, and until then it runs through the bridge, in the + * same menus and the same dialogs, and nobody has to notice. + *

+ *

+ * NB: the caller owns the legacy context, because the caller knows which + * services it wants and when to dispose of it. Two contexts exist while both + * worlds do - one for each - which is the honest arrangement: they share no + * services and neither can see the other's. + *

+ * + * @author Curtis Rueden + */ +public final class Legacy { + + private Legacy() { + // prevent instantiation of utility class + } + + /** + * Gets everything the given SciJava Common context can run. + * + * @param context a SciJava Common context, which the caller owns + * @return its modules, as commands, in menu order + */ + public static List commands(final org.scijava.Context context) { + final ModuleService modules = context.getService(ModuleService.class); + if (modules == null) { + throw new IllegalArgumentException( + "The legacy context has no ModuleService"); + } + return commands(modules.getModules(), context); + } + + /** + * Gets the given modules as commands, in menu order. + * + * @param infos the modules to present + * @param context the legacy context they belong to + */ + public static List commands( + final List infos, final org.scijava.Context context) + { + return infos.stream() // + .map(info -> new LegacyCommandInfo(info, context)) // + .sorted(Comparator.comparingDouble(CommandInfo::weight) // + .thenComparing(CommandInfo::label)) // + .collect(Collectors.toList()); + } +} diff --git a/compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java b/compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java new file mode 100644 index 000000000..e393390c1 --- /dev/null +++ b/compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java @@ -0,0 +1,372 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.scijava.MenuEntry; +import org.scijava.MenuPath; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; +import org.scijava.execute.Behavior; +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.module.Module; +import org.scijava.module.MethodCallException; +import org.scijava.module.ModuleException; +import org.scijava.module.ModuleInfo; +import org.scijava.module.ModuleItem; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * A SciJava Common module, seen as a SciJava3 command. + *

+ * This is the whole of the legacy bridge's promise: an application built on + * SciJava3 can show, harvest and run everything SciJava Common already knows + * about - every ImageJ2 command, every Fiji plugin that was ported to the + * module system - beside its own commands, in its own menus and dialogs, with + * no change to either side. The dependency goes one way and lives here; + * SciJava Common knows nothing about SciJava3. + *

+ *

+ * NB: nothing is loaded or constructed to build one of these. A + * {@code ModuleInfo} already describes its parameters without instantiating + * anything, which is the property that makes a menu of ten thousand legacy + * commands cost nothing - the same property the SciJava3 annotation index has. + *

+ * + * @author Curtis Rueden + */ +public class LegacyCommandInfo implements CommandInfo { + + private final ModuleInfo info; + private final org.scijava.Context context; + + /** + * @param info the module to present as a command + * @param context the legacy context, which the module is injected with - + * exactly as SciJava Common's own {@code ModuleService} does, and + * without which a module that uses a legacy service (a + * {@code DynamicCommand}, for one) fails the moment it runs + */ + public LegacyCommandInfo(final ModuleInfo info, + final org.scijava.Context context) + { + this.info = info; + this.context = context; + } + + /** Gets the module description behind this command. */ + public ModuleInfo info() { + return info; + } + + // -- CommandInfo methods -- + + @Override + public Optional menuPath() { + final MenuPath path = info.getMenuPath(); + if (path == null || path.isEmpty()) return Optional.empty(); + // NB: not getMenuString(), which renders "A > B > C" with spaces around + // the separator. SciJava3 splits on ">" and would carry the spaces into + // the labels, so the path is rebuilt from the entries themselves. + return Optional.of(path.stream().map(MenuEntry::getName) // + .collect(java.util.stream.Collectors.joining(MenuTree.SEPARATOR))); + } + + @Override + public String label() { + // NB: SciJava Common's own answer to this question, which prefers the + // label, then the menu leaf, then the name. + return info.getTitle(); + } + + @Override + public Optional accelerator() { + return leaf().map(MenuEntry::getAccelerator).map( + LegacyCommandInfo::accelerator).filter(s -> !s.isEmpty()); + } + + @Override + public Optional iconPath() { + return Optional.ofNullable(info.getIconPath()).filter(s -> !s.isEmpty()); + } + + @Override + public double weight() { + return leaf().map(MenuEntry::getWeight).orElse(Double.POSITIVE_INFINITY); + } + + @Override + public boolean isVisible() { + return info.isVisible(); + } + + // -- Executable methods -- + + @Override + public String name() { + return info.getDelegateClassName(); + } + + @Override + public Struct struct() { + return structOf(info); + } + + /** + * Describes the parameters of a module description. + *

+ * NB: service and context parameters are left out. SciJava Common's + * {@code @Parameter} means both "this is an input" and "inject this + * service", and its harvester tells them apart by having preprocessors fill + * the services first. SciJava3 split the two annotations precisely so that + * this question never arises, so the bridge answers it here: what the + * legacy context injects is not something to ask the user for. + *

+ */ + private static Struct structOf(final ModuleInfo info) { + final List> members = new ArrayList<>(); + for (final ModuleItem item : info.inputs()) { + if (isInjected(item)) continue; + members.add(new LegacyMember<>(item)); + } + for (final ModuleItem item : info.outputs()) { + // NB: a both-directions item appears in each list; it is one parameter. + if (item.isInput() || isInjected(item)) continue; + members.add(new LegacyMember<>(item)); + } + return () -> members; + } + + /** Gets whether the legacy context fills this parameter itself. */ + private static boolean isInjected(final ModuleItem item) { + final Class type = item.getType(); + return org.scijava.service.Service.class.isAssignableFrom(type) || // + org.scijava.Context.class.isAssignableFrom(type); + } + + @Override + public ExecutableInstance create() { + final Module module; + try { + module = info.createModule(); + } + catch (final ModuleException exc) { + throw new IllegalStateException("Cannot create legacy command: " + + name(), exc); + } + // NB: what SciJava Common's ModuleService.createModule does, and for the + // same reason: a legacy command may hold @Parameter services, and a + // DynamicCommand needs its own context to build its parameters at all. + context.inject(module); + org.scijava.Priority.inject(module, info.getPriority()); + // NB: and what its InitPreprocessor does, before anything harvests. A + // DynamicCommand adds its parameters here, so initializing later would + // mean showing the user a dialog missing half its rows. + try { + module.initialize(); + } + catch (final MethodCallException exc) { + throw new IllegalStateException("Cannot initialize legacy command: " + + name(), exc); + } + return new Instance(module); + } + + @Override + public String toString() { + return name() + menuPath().map(p -> " [" + p + "]").orElse(""); + } + + // -- Helper methods -- + + /** + * Translates a legacy accelerator into the notation SciJava3 uses. + *

+ * NB: not {@code Accelerator.toString()}, which renders Swing's format + * ({@code "control L"}). Swing would parse that back, but JavaFX would not, + * and a shortcut that works in one toolkit and silently vanishes in another + * is exactly the sort of thing the bridge exists to prevent. Written as + * {@code ^L}, every binding understands it - and control or meta both + * become {@code ^}, which is what "the platform's menu shortcut" means. + *

+ */ + private static String accelerator(final org.scijava.input.Accelerator acc) { + if (acc == null || acc.getKeyCode() == null) return ""; + final org.scijava.input.InputModifiers mods = acc.getModifiers(); + final StringBuilder sb = new StringBuilder(); + if (mods != null) { + if (mods.isCtrlDown() || mods.isMetaDown()) sb.append('^'); + if (mods.isAltDown()) sb.append('!'); + if (mods.isShiftDown()) sb.append('+'); + } + return sb.append(acc.getKeyCode().name()).toString(); + } + + /** + * Gets what to tell the user about a failed validation. + *

+ * NB: the innermost message. A validator that throws has its exception + * wrapped by SciJava Common in a {@code MethodCallException} whose own + * message is "Error executing method: ...", which tells a user nothing; the + * message the validator actually wrote is underneath. A validator that + * returns its message instead has no cause, and that message is the + * exception's own. + *

+ */ + private static String problem(final MethodCallException exc) { + Throwable deepest = exc; + while (deepest.getCause() != null) + deepest = deepest.getCause(); + final String message = deepest.getMessage(); + return message == null || message.isEmpty() ? exc.getMessage() : message; + } + + private Optional leaf() { + final MenuPath path = info.getMenuPath(); + return path == null || path.isEmpty() ? Optional.empty() // + : Optional.ofNullable(path.getLeaf()); + } + + /** One run of a legacy command, backed by a SciJava Common module. */ + private class Instance implements ExecutableInstance { + + private final Module module; + private final StructInstance parameters; + + Instance(final Module module) { + this.module = module; + // NB: the *module's* description, not this command's. They are the + // same object for an ordinary command, and for a DynamicCommand the + // module's carries the parameters it just built for itself. That + // mutable-per-instance description is what SciJava3 declined to offer + // as a way of writing commands -- and presenting one costs nothing. + final Struct struct = structOf(module.getInfo()); + final List> members = new ArrayList<>(); + for (final Member member : struct.members()) { + members.add(((LegacyMember) member).instance(module)); + } + parameters = new StructInstance<>() { + + @Override + public Struct struct() { + return struct; + } + + @Override + public Module object() { + return module; + } + + @Override + public List> members() { + return members; + } + + @Override + public MemberInstance member(final String key) { + return members.stream() // + .filter(m -> m.member().key().equals(key)) // + .findFirst().orElse(null); + } + }; + } + + @Override + public Executable executable() { + return LegacyCommandInfo.this; + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + // NB: initialization already happened, at create() time. The legacy + // preprocessor chain is deliberately not involved: this is a SciJava3 + // run, and two chains harvesting the same inputs would be one too + // many. + module.run(); + } + + @Override + public Optional behavior(final String name) { + // NB: SciJava3 names a behavior and asks the executable what the name + // means. For a legacy command it means a method on the module's + // delegate object, which SciJava Common knows how to invoke - so a + // legacy dialog behaves in a SciJava3 application exactly as it did + // in an ImageJ2 one, without either side being changed. + for (final ModuleItem item : module.getInfo().inputs()) { + if (name.equals(item.getCallback())) { + return Optional.of(args -> { + try { + item.callback(module); + } + catch (final MethodCallException exc) { + throw new IllegalStateException("Legacy callback '" + name + + "' failed", exc); + } + return null; + }); + } + if (name.equals(item.getValidater())) { + // NB: SciJava Common lets a validator either throw or return a + // non-empty message, which is why every caller there has to + // handle both. SciJava3 has one protocol - return a message, or + // do not - and the two collapse into it here, which is the + // whole cost of that simplification. + // + // (Released SciJava Common offers only validate(), which throws + // a MethodCallException carrying the returned message. A + // validateMessage() exists in its development line and would be + // this same thing; using validate() works against both.) + return Optional.of(args -> { + try { + item.validate(module); + return null; + } + catch (final MethodCallException exc) { + return problem(exc); + } + }); + } + } + return Optional.empty(); + } + } +} diff --git a/compat/src/main/java/org/scijava/compat/LegacyContext.java b/compat/src/main/java/org/scijava/compat/LegacyContext.java new file mode 100644 index 000000000..2b50284d8 --- /dev/null +++ b/compat/src/main/java/org/scijava/compat/LegacyContext.java @@ -0,0 +1,113 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import java.util.List; + +import org.scijava.command3.CommandInfo; + +/** + * The old world, running alongside the new one. + *

+ * It owns a SciJava Common context and hands out what that context can run, as + * SciJava3 commands: + *

+ * + *
+ * try (LegacyContext legacy = LegacyContext.start()) {
+ * 	entries.addAll(legacy.commands());
+ * }
+ * 
+ *

+ * NB: no SciJava Common type appears in this class's signatures, deliberately. + * A JPMS module using the bridge would otherwise have to {@code requires + * org.scijava} merely to name the context it is holding - which is a great + * deal of coupling for something an application wants to keep at arm's length, + * and impossible to express for test-scoped use at all. An application that + * already owns a legacy context, as Fiji will, passes it to + * {@link Legacy#commands} instead. + *

+ * + * @author Curtis Rueden + */ +public class LegacyContext implements AutoCloseable { + + private final org.scijava.Context context; + + private LegacyContext(final org.scijava.Context context) { + this.context = context; + } + + /** Starts a legacy context with every service it can find. */ + public static LegacyContext start() { + return new LegacyContext(new org.scijava.Context()); + } + + /** + * Wraps a legacy context the caller already has. + * + * @param context a SciJava Common context; closing this does not dispose it, + * the caller having made it + */ + public static LegacyContext of(final Object context) { + if (!(context instanceof org.scijava.Context)) { + throw new IllegalArgumentException("Not a SciJava Common context: " + // + (context == null ? "null" : context.getClass().getName())); + } + return new LegacyContext((org.scijava.Context) context) { + + @Override + public void close() { + // NB: not ours to dispose. + } + }; + } + + /** Gets everything this context can run, as commands, in menu order. */ + public List commands() { + return Legacy.commands(context); + } + + /** + * Gets the SciJava Common context itself. + *

+ * Typed as {@link Object} so that naming it costs a caller nothing; cast it + * when you mean to use it. + *

+ */ + public Object context() { + return context; + } + + @Override + public void close() { + context.dispose(); + } +} diff --git a/compat/src/main/java/org/scijava/compat/LegacyMember.java b/compat/src/main/java/org/scijava/compat/LegacyMember.java new file mode 100644 index 000000000..f77aed2f8 --- /dev/null +++ b/compat/src/main/java/org/scijava/compat/LegacyMember.java @@ -0,0 +1,171 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.scijava.execute.ParameterMember; +import org.scijava.module.Module; +import org.scijava.module.ModuleItem; +import org.scijava.struct.ItemIO; +import org.scijava.struct.MemberInstance; + +/** + * A SciJava Common {@link ModuleItem}, seen as a SciJava3 parameter. + *

+ * The metadata translates almost exactly, which is the point: both describe + * the same thing, and SciJava3's attributes were named after SciJava Common's + * on purpose. A legacy command therefore arrives in a SciJava3 dialog with its + * labels, bounds, choices and widget styles intact, and the widgets have no + * idea they are rendering something from the old world. + *

+ * + * @author Curtis Rueden + */ +class LegacyMember implements ParameterMember { + + private final ModuleItem item; + + LegacyMember(final ModuleItem item) { + this.item = item; + } + + /** Gets the module item behind this parameter. */ + ModuleItem item() { + return item; + } + + @Override + public String key() { + return item.getName(); + } + + @Override + public Type type() { + return item.getGenericType(); + } + + @Override + public String description() { + return item.getDescription(); + } + + @Override + public boolean isRequired() { + return item.isRequired(); + } + + @Override + public ItemIO getIOType() { + switch (item.getIOType()) { + case OUTPUT: + return ItemIO.OUTPUT; + case BOTH: + return ItemIO.MUTABLE; + default: + return ItemIO.INPUT; + } + } + + @Override + public Map attrs() { + final Map attrs = new LinkedHashMap<>(); + put(attrs, LABEL, item.getLabel()); + put(attrs, STYLE, item.getWidgetStyle()); + put(attrs, MIN, string(item.getMinimumValue())); + put(attrs, MAX, string(item.getMaximumValue())); + put(attrs, SOFT_MIN, string(item.getSoftMinimum())); + put(attrs, SOFT_MAX, string(item.getSoftMaximum())); + put(attrs, STEP_SIZE, string(item.getStepSize())); + if (item.getChoices() != null && !item.getChoices().isEmpty()) { + put(attrs, CHOICES, item.getChoices().stream().map(String::valueOf) // + .collect(Collectors.joining(","))); + } + // NB: the callback's *name*, because that is how SciJava3 asks for one: + // the dialog model reads this attribute and then calls + // ExecutableInstance.behavior(name). What that name means is the + // executable's business, and for a legacy command it means a method on + // the module's delegate object, invoked through ModuleItem.callback -- + // see LegacyCommandInfo. Both worlds name a callback; only the + // resolution differs. + put(attrs, CALLBACK, item.getCallback()); + // NB: and the validator, which SciJava Common spells with an 'e'. + put(attrs, VALIDATOR, item.getValidater()); + return attrs; + } + + /** Reads this parameter out of the given module. */ + MemberInstance instance(final Module module) { + return new MemberInstance<>() { + + @Override + public LegacyMember member() { + return LegacyMember.this; + } + + @Override + public T get() { + return item.getValue(module); + } + + @Override + public void set(final Object value) { + @SuppressWarnings("unchecked") + final T typed = (T) value; + item.setValue(module, typed); + } + + @Override + public boolean isReadable() { + return true; + } + + @Override + public boolean isWritable() { + return true; + } + }; + } + + // -- Helper methods -- + + private static String string(final Object value) { + return value == null ? null : String.valueOf(value); + } + + private static void put(final Map attrs, final String key, + final String value) + { + if (value != null && !value.isEmpty()) attrs.put(key, value); + } +} diff --git a/compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java b/compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java new file mode 100644 index 000000000..7cc11e35e --- /dev/null +++ b/compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java @@ -0,0 +1,231 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; +import org.scijava.execute.Behavior; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.ParameterMember; +import org.scijava.execute.Preprocessor; +import org.scijava.execute.Runner; + +/** + * Tests that SciJava Common's modules arrive as SciJava3 commands. + * + * @author Curtis Rueden + */ +public class LegacyBridgeTest { + + private static org.scijava.Context legacy; + + @BeforeAll + public static void startLegacyContext() { + legacy = new org.scijava.Context(); + } + + @AfterAll + public static void disposeLegacyContext() { + if (legacy != null) legacy.dispose(); + } + + /** Every legacy module is a command, without anything being loaded. */ + @Test + public void testDiscovery() { + final List commands = Legacy.commands(legacy); + + assertTrue(commands.size() > 2, "found " + commands.size()); + assertTrue(names(commands).contains(LegacyCommands.LegacyBlur.class + .getName()), names(commands).toString()); + } + + /** Its menu metadata translates, so it lands where it always did. */ + @Test + public void testMenuMetadata() { + final CommandInfo blur = command(LegacyCommands.LegacyBlur.class); + + assertEquals(Optional.of("Process>Filters>Legacy Blur..."), blur + .menuPath()); + assertEquals("Legacy Blur...", blur.label()); + assertEquals(Optional.of("/icons/blur.png"), blur.iconPath()); + assertEquals(7.0, blur.weight()); + assertTrue(blur.isVisible()); + // NB: translated into SciJava3's notation, not Swing's -- so JavaFX and + // AWT can read it too. + assertEquals(Optional.of("^L"), blur.accelerator()); + } + + /** And it sits in a SciJava3 menu tree beside anything else. */ + @Test + public void testInAMenuTree() { + final MenuTree filters = MenuTree.of(Legacy.commands(legacy)) // + .find("Process>Filters").orElseThrow(); + + assertEquals(List.of("Legacy Blur..."), filters.children().stream() // + .map(MenuTree::label).collect(Collectors.toList())); + } + + /** Service parameters are the context's business, not the user's. */ + @Test + public void testInjectedParametersAreHidden() { + final CommandInfo dynamic = command(LegacyCommands.LegacyDynamic.class); + + final List keys = dynamic.struct().members().stream() // + .map(m -> m.key()).collect(Collectors.toList()); + + // NB: DynamicCommand declares context, commandService, pluginService and + // moduleService as @Parameter fields, SciJava Common having used one + // annotation for injection and for inputs. A dialog must not ask for + // them. + assertEquals(List.of("name"), keys); + } + + /** Its parameters translate, with the metadata a dialog needs. */ + @Test + public void testParameters() { + final CommandInfo blur = command(LegacyCommands.LegacyBlur.class); + final var members = blur.struct().members(); + + assertEquals(List.of("sigma", "edges", "input", "result"), members + .stream().map(m -> m.key()).collect(Collectors.toList())); + + final ParameterMember sigma = (ParameterMember) members.get(0); + assertEquals(Optional.of("Blur radius"), sigma.attr( + ParameterMember.LABEL)); + // NB: "0.0" rather than "0": SciJava Common parsed the annotation's + // string into the parameter's own type, and the bridge reports what it + // holds. Either spelling converts to the same double. + assertEquals(Optional.of("0.0"), sigma.attr(ParameterMember.MIN)); + assertEquals(Optional.of("20.0"), sigma.attr(ParameterMember.MAX)); + assertEquals(Optional.of("0.5"), sigma.attr(ParameterMember.STEP_SIZE)); + assertEquals(Optional.of("slider"), sigma.attr(ParameterMember.STYLE)); + + final ParameterMember edges = (ParameterMember) members.get(1); + assertEquals(Optional.of("Reflect,Zero,Wrap"), edges.attr( + ParameterMember.CHOICES)); + + assertTrue(members.get(0).isRequired()); + assertFalse(members.get(2).isRequired(), "input is not required"); + assertTrue(members.get(3).isOutput(), "result is an output"); + } + + /** It runs through the SciJava3 Runner, with SciJava3 inputs. */ + @Test + public void testRunsThroughTheSciJava3Runner() throws Exception { + final CommandInfo blur = command(LegacyCommands.LegacyBlur.class); + + final Future future = Runner.of(List.of(), List.of()) // + .run(blur, Map.of("sigma", "3.5", "edges", "Wrap")); + final ExecutionResult result = future.get(); + + assertFalse(result.isDeclined()); + // NB: "3.5" was a string, and the parameter is a double: convert3 did + // that, exactly as it does for a SciJava3 command. + assertEquals("Legacy blur: sigma=3.5 edges=Wrap", result.outputs().get( + "result")); + } + + /** SciJava3's preprocessors apply to it, including a harvester. */ + @Test + public void testSciJava3PreprocessorsApply() throws Exception { + final Preprocessor filler = execution -> execution.instance().member( + "edges").set("Zero"); + final CommandInfo blur = command(LegacyCommands.LegacyBlur.class); + + final ExecutionResult result = Runner.of(List.of(filler), List.of()) // + .run(blur, Map.of("sigma", 1.0)).get(); + + assertEquals("Legacy blur: sigma=1.0 edges=Zero", result.outputs().get( + "result")); + } + + /** A legacy callback fires the SciJava3 way, so its dialog still behaves. */ + @Test + public void testLegacyCallback() { + final CommandInfo temperature = command( + LegacyCommands.LegacyTemperature.class); + final ExecutableInstance instance = temperature.create(); + instance.parameters().member("celsius").set(100.0); + + final Optional callback = instance.behavior("celsiusChanged"); + assertTrue(callback.isPresent(), "the legacy callback, by its name"); + callback.get().invoke(); + + assertEquals(212.0, instance.parameters().member("fahrenheit").get()); + } + + /** + * A DynamicCommand's run-time parameters come across too. + *

+ * NB: SciJava3 offers no way to write one, and does not need to in order to + * present one. What the module reports when asked is what the dialog shows. + *

+ */ + @Test + public void testDynamicCommand() { + final CommandInfo dynamic = command(LegacyCommands.LegacyDynamic.class); + + // NB: creating it initializes it, which is when it builds its extra + // parameter -- before anything would harvest. + final ExecutableInstance instance = dynamic.create(); + + final List keys = instance.parameters().members().stream() // + .map(m -> m.member().key()).collect(Collectors.toList()); + assertEquals(List.of("name", "addedAtRuntime"), keys); + assertEquals(42, instance.parameters().member("addedAtRuntime").get()); + } + + // -- Helper methods -- + + private static CommandInfo command(final Class type) { + return Legacy.commands(legacy).stream() // + .filter(c -> c.name().equals(type.getName())) // + .findFirst().orElseThrow(() -> new AssertionError("Not found: " + type)); + } + + private static List names(final List commands) { + return commands.stream().map(CommandInfo::name).collect(Collectors + .toList()); + } +} diff --git a/compat/src/test/java/org/scijava/compat/LegacyCommands.java b/compat/src/test/java/org/scijava/compat/LegacyCommands.java new file mode 100644 index 000000000..a9efca83c --- /dev/null +++ b/compat/src/test/java/org/scijava/compat/LegacyCommands.java @@ -0,0 +1,161 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import java.io.File; + +import org.scijava.ItemIO; +import org.scijava.command.Command; +import org.scijava.command.DynamicCommand; +import org.scijava.module.MutableModuleItem; +import org.scijava.plugin.Menu; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; + +/** + * Commands written the SciJava Common way: the old annotations, the old + * interfaces, the old everything. + *

+ * NB: nothing here imports a single SciJava3 class. That is the test - this is + * what the existing ecosystem looks like, and the bridge has to take it as it + * finds it. + *

+ * + * @author Curtis Rueden + */ +public final class LegacyCommands { + + private LegacyCommands() { + // prevent instantiation of utility class + } + + /** An ordinary legacy command, with the metadata one carries. */ + @Plugin(type = Command.class, menu = { // + @Menu(label = "Process", weight = 20), // + @Menu(label = "Filters"), // + @Menu(label = "Legacy Blur...", weight = 7, accelerator = "^L") }, // + iconPath = "/icons/blur.png") + public static class LegacyBlur implements Command { + + @Parameter(label = "Blur radius", min = "0", max = "20", stepSize = "0.5", + style = "slider") + private double sigma = 2; + + @Parameter(choices = { "Reflect", "Zero", "Wrap" }) + private String edges = "Reflect"; + + @Parameter(required = false) + private File input; + + @Parameter(type = ItemIO.OUTPUT) + private String result; + + @Override + public void run() { + result = "Legacy blur: sigma=" + sigma + " edges=" + edges; + } + } + + /** A legacy command with a callback, the SciJava Common way. */ + @Plugin(type = Command.class, menuPath = "Edit>Legacy Temperature") + public static class LegacyTemperature implements Command { + + @Parameter(callback = "celsiusChanged") + private double celsius = 0; + + @Parameter + private double fahrenheit = 32; + + @SuppressWarnings("unused") + private void celsiusChanged() { + fahrenheit = celsius * 9 / 5 + 32; + } + + @Override + public void run() {} + } + + /** + * A legacy command with a validator. + *

+ * NB: it throws to report a bad value, which is what released + * SciJava Common honors. Returning the message instead works on its + * development line (the validation mechanism was improved in March 2026, + * after 2.99.2), and the bridge maps either onto SciJava3's single + * protocol - so this fixture tests the one every existing command in the + * wild is written against. + *

+ */ + @Plugin(type = Command.class, menuPath = "Edit>Legacy Validation") + public static class LegacyValidation implements Command { + + @Parameter(validater = "checkCount") + private int count = 5; + + @SuppressWarnings("unused") + private void checkCount() { + if (count < 1 || count > 10) { + throw new IllegalArgumentException( + "Count must be between 1 and 10"); + } + } + + @Override + public void run() {} + } + + /** + * The evil one: a command that builds its own parameters at run time. + *

+ * NB: {@code DynamicCommand} is what SciJava3 deliberately has no + * equivalent of - and the bridge does not need one, because a + * {@code ModuleInfo} reports whatever parameters it has by the time it is + * asked. What SciJava3 refuses to offer as a way of writing new + * commands, it can still present. + *

+ */ + @Plugin(type = Command.class, menuPath = "Analyze>Legacy Dynamic") + public static class LegacyDynamic extends DynamicCommand { + + @Parameter + private String name = "ada"; + + @Override + public void initialize() { + final MutableModuleItem added = addInput("addedAtRuntime", + Integer.class); + added.setLabel("Added at run time"); + added.setValue(this, 42); + } + + @Override + public void run() {} + } +} diff --git a/compat/src/test/java/org/scijava/compat/LegacyDialogTest.java b/compat/src/test/java/org/scijava/compat/LegacyDialogTest.java new file mode 100644 index 000000000..2806800ae --- /dev/null +++ b/compat/src/test/java/org/scijava/compat/LegacyDialogTest.java @@ -0,0 +1,142 @@ +/* + * #%L + * Presenting SciJava Common's modules as SciJava3 commands. + * %% + * Copyright (C) 2026 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.compat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.scijava.command3.CommandInfo; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.Widgets; + +/** + * Tests that a legacy command produces a SciJava3 dialog model. + *

+ * This is where the bridge's claim ends, and deliberately: a command written + * years ago against the old annotations, in a jar nobody is going to + * recompile, described well enough that any toolkit can render it - labels, + * bounds, slider style, choices - with its callbacks firing when a value + * changes. That a correct model becomes correct widgets is established by the + * bindings' shared conformance suite, in Swing, JavaFX and AWT alike. + *

+ * + * @author Curtis Rueden + */ +public class LegacyDialogTest { + + private static LegacyContext legacy; + + @BeforeAll + public static void startLegacyContext() { + // NB: no SciJava Common type is named here, which is the point: + // LegacyContext keeps them out of the bridge's signatures, so a module + // using the bridge need not `requires org.scijava`. + legacy = LegacyContext.start(); + } + + @AfterAll + public static void disposeLegacyContext() { + if (legacy != null) legacy.close(); + } + + /** The dialog shows the parameters, and only the parameters. */ + @Test + public void testTheDialogsShape() { + final ParameterModel model = model("LegacyBlur"); + + assertEquals(List.of("Blur radius", "Edges", "Input"), model.tree().nodes() + .stream().map(ParameterNode::label).collect(Collectors.toList())); + } + + /** Legacy widget metadata survives, in the form a widget asks for. */ + @Test + public void testWidgetMetadata() { + final ParameterModel model = model("LegacyBlur"); + final ParameterNode sigma = model.tree().find("sigma").orElseThrow(); + + assertEquals(0.0, Widgets.min(sigma)); + assertEquals(20.0, Widgets.max(sigma)); + assertEquals(0.5, Widgets.stepSize(sigma)); + assertTrue(Widgets.isStyle(sigma, "slider"), "the legacy widget style"); + assertTrue(Widgets.isNumber(sigma)); + + final ParameterNode edges = model.tree().find("edges").orElseThrow(); + assertEquals(List.of("Reflect", "Zero", "Wrap"), edges.choices()); + } + + /** A legacy callback fires when the dialog writes a value. */ + @Test + public void testCallbackThroughTheDialogModel() { + final ParameterModel model = model("LegacyTemperature"); + + model.set("celsius", 100.0); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** A legacy validator reports problems the SciJava3 way. */ + @Test + public void testLegacyValidator() { + final ParameterModel model = model("LegacyValidation"); + + model.set("count", 5); + assertTrue(model.problems().isEmpty(), model.problems().toString()); + + model.set("count", 42); + assertEquals("Count must be between 1 and 10", model.problems().get( + "count")); + } + + /** A DynamicCommand's run-time parameters are in the dialog too. */ + @Test + public void testDynamicParametersInTheDialog() { + final ParameterModel model = model("LegacyDynamic"); + + assertEquals(List.of("Name", "Added at run time"), model.tree().nodes() + .stream().map(ParameterNode::label).collect(Collectors.toList())); + } + + // -- Helper methods -- + + private static ParameterModel model(final String simpleName) { + final CommandInfo command = legacy.commands().stream() // + .filter(c -> c.name().endsWith("$" + simpleName)) // + .findFirst().orElseThrow(() -> new AssertionError("Not found: " + + simpleName)); + return new ParameterModel(command.create()); + } +} diff --git a/scijava-concurrent/LICENSE.txt b/concurrent/LICENSE.txt similarity index 100% rename from scijava-concurrent/LICENSE.txt rename to concurrent/LICENSE.txt diff --git a/scijava-concurrent/README.md b/concurrent/README.md similarity index 100% rename from scijava-concurrent/README.md rename to concurrent/README.md diff --git a/scijava-concurrent/pom.xml b/concurrent/pom.xml similarity index 100% rename from scijava-concurrent/pom.xml rename to concurrent/pom.xml diff --git a/scijava-concurrent/src/main/java/module-info.java b/concurrent/src/main/java/module-info.java similarity index 100% rename from scijava-concurrent/src/main/java/module-info.java rename to concurrent/src/main/java/module-info.java diff --git a/scijava-concurrent/src/main/java/org/scijava/concurrent/Parallelization.java b/concurrent/src/main/java/org/scijava/concurrent/Parallelization.java similarity index 100% rename from scijava-concurrent/src/main/java/org/scijava/concurrent/Parallelization.java rename to concurrent/src/main/java/org/scijava/concurrent/Parallelization.java diff --git a/scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutor.java b/concurrent/src/main/java/org/scijava/concurrent/TaskExecutor.java similarity index 100% rename from scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutor.java rename to concurrent/src/main/java/org/scijava/concurrent/TaskExecutor.java diff --git a/scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java b/concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java similarity index 94% rename from scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java rename to concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java index 0ff629cc2..581e930ba 100644 --- a/scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java +++ b/concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java @@ -36,6 +36,7 @@ import java.util.List; import java.util.concurrent.AbstractExecutorService; import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -260,7 +261,10 @@ public List forEachApply(List parameters, results.add(future.get()); return results; } - catch (InterruptedException | ExecutionException e) { + catch (final InterruptedException e) { + throw cancelled(); + } + catch (final ExecutionException e) { throw unwrapExecutionException(e); } } @@ -271,7 +275,10 @@ private void invokeAllIgnoreResults(final List> callables) { for (var future : futures) future.get(); } - catch (InterruptedException | ExecutionException e) { + catch (final InterruptedException e) { + throw cancelled(); + } + catch (final ExecutionException e) { throw unwrapExecutionException(e); } } @@ -282,6 +289,22 @@ private void invokeAllIgnoreResults(final List> callables) { * read. This method unwraps the {@link ExecutionException} and thereby * reveals the original exception, and ensures it's complete stack trace. */ + /** + * Reports that this thread was cancelled while awaiting its workers. + *

+ * NB: {@link ExecutorService#invokeAll} has already cancelled the + * unfinished tasks, which interrupts their threads. What is left is this + * thread's own flag, which {@link InterruptedException} cleared on its + * way out; restoring it is what lets code further up the stack see that + * the work was cancelled rather than that it failed. + *

+ */ + private CancellationException cancelled() { + Thread.currentThread().interrupt(); + return new CancellationException("Cancelled: " + Thread.currentThread() + .getName()); + } + private RuntimeException unwrapExecutionException(Throwable e) { if (e instanceof ExecutionException) { final var cause = e.getCause(); diff --git a/concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java b/concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java new file mode 100644 index 000000000..5073dd127 --- /dev/null +++ b/concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java @@ -0,0 +1,102 @@ +/*- + * #%L + * SciJava library facilitating consistent parallel processing. + * %% + * Copyright (C) 2021 - 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.concurrent; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +/** + * Tests that cancelling a thread which farmed work out to a + * {@link TaskExecutor} reaches the workers, and is still visible afterward. + *

+ * The interrupt flag does not inherit to worker threads, so this propagation + * is deliberate rather than automatic. + *

+ * + * @author Curtis Rueden + */ +public class CancellationTest { + + /** + * Interrupting the calling thread cancels the workers, surfaces as a + * {@link CancellationException}, and leaves the caller's interrupt flag set. + */ + @Test + public void testInterruptionReachesWorkers() throws Exception { + final CountDownLatch allStarted = new CountDownLatch(4); + final AtomicInteger interruptedWorkers = new AtomicInteger(); + final AtomicReference thrown = new AtomicReference<>(); + final AtomicBoolean flagStillSet = new AtomicBoolean(); + + final Thread caller = new Thread(() -> { + try (final TaskExecutor executor = TaskExecutors.fixedThreadPool(4)) { + executor.forEach(List.of(1, 2, 3, 4), i -> { + allStarted.countDown(); + try { + // NB: long enough that the interrupt lands mid-flight. + Thread.sleep(30_000); + } + catch (final InterruptedException exc) { + interruptedWorkers.incrementAndGet(); + Thread.currentThread().interrupt(); + } + }); + } + catch (final RuntimeException exc) { + thrown.set(exc); + flagStillSet.set(Thread.currentThread().isInterrupted()); + } + }); + caller.start(); + assertTrue(allStarted.await(10, TimeUnit.SECONDS), "workers never started"); + + caller.interrupt(); + caller.join(10_000); + + final RuntimeException exc = thrown.get(); + assertNotNull(exc, "the caller was not cancelled"); + assertTrue(exc instanceof CancellationException, + "expected CancellationException, got: " + exc); + assertTrue(flagStillSet.get(), + "the caller's interrupt flag must be restored"); + assertTrue(interruptedWorkers.get() > 0, + "cancellation never reached the workers"); + } +} diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/DefaultTaskExecutorTest.java b/concurrent/src/test/java/org/scijava/concurrent/DefaultTaskExecutorTest.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/DefaultTaskExecutorTest.java rename to concurrent/src/test/java/org/scijava/concurrent/DefaultTaskExecutorTest.java diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/ParallelizationBenchmark.java b/concurrent/src/test/java/org/scijava/concurrent/ParallelizationBenchmark.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/ParallelizationBenchmark.java rename to concurrent/src/test/java/org/scijava/concurrent/ParallelizationBenchmark.java diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/ParallelizationDemo.java b/concurrent/src/test/java/org/scijava/concurrent/ParallelizationDemo.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/ParallelizationDemo.java rename to concurrent/src/test/java/org/scijava/concurrent/ParallelizationDemo.java diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/ParallelizationTest.java b/concurrent/src/test/java/org/scijava/concurrent/ParallelizationTest.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/ParallelizationTest.java rename to concurrent/src/test/java/org/scijava/concurrent/ParallelizationTest.java diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/TaskExecutorsTest.java b/concurrent/src/test/java/org/scijava/concurrent/TaskExecutorsTest.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/TaskExecutorsTest.java rename to concurrent/src/test/java/org/scijava/concurrent/TaskExecutorsTest.java diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/package-info.java b/concurrent/src/test/java/org/scijava/concurrent/package-info.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/package-info.java rename to concurrent/src/test/java/org/scijava/concurrent/package-info.java diff --git a/context-test/pom.xml b/context-test/pom.xml new file mode 100644 index 000000000..c8e621ce3 --- /dev/null +++ b/context-test/pom.xml @@ -0,0 +1,173 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-context-test + 1.0.0-SNAPSHOT + + SciJava Context Test + Integration tests for the scijava-context library. + https://github.com/scijava/scijava + 2021 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + None + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.context.test + + bsd_2 + SciJava developers. + + + true + true + + + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-context + ${scijava-context.version} + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/context-test/src/main/java/module-info.java b/context-test/src/main/java/module-info.java new file mode 100644 index 000000000..8e1de8a7d --- /dev/null +++ b/context-test/src/main/java/module-info.java @@ -0,0 +1,45 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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% + */ + +module org.scijava.context.test { + + // NB: the implementation package is deliberately NOT exported. It is + // opened to the container only, which is what plugin construction needs -- + // and opens is not exports, so callers still cannot reach these classes. + exports org.scijava.context.test; + opens org.scijava.context.test.impl to org.scijava.context; + + requires org.scijava.context; + requires org.scijava.discovery; + + provides org.scijava.context.Service with + org.scijava.context.test.impl.HiddenGreeter, + org.scijava.context.test.impl.DefaultRecordingService; + +} diff --git a/context-test/src/main/java/org/scijava/context/test/GreeterService.java b/context-test/src/main/java/org/scijava/context/test/GreeterService.java new file mode 100644 index 000000000..644e04874 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/GreeterService.java @@ -0,0 +1,38 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +import org.scijava.context.Service; + +/** A service interface, exported for callers to ask for. */ +public interface GreeterService extends Service { + + String greet(); +} diff --git a/context-test/src/main/java/org/scijava/context/test/RecordingService.java b/context-test/src/main/java/org/scijava/context/test/RecordingService.java new file mode 100644 index 000000000..c57703c05 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/RecordingService.java @@ -0,0 +1,40 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +import java.util.List; + +import org.scijava.context.Service; + +/** A service that records the shouts it hears. */ +public interface RecordingService extends Service { + + List recorded(); +} diff --git a/context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java b/context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java new file mode 100644 index 000000000..8c1fb04f7 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java @@ -0,0 +1,44 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +/** An event, needing no base type. */ +public class ShoutRecorded { + + private final String text; + + public ShoutRecorded(final String text) { + this.text = text; + } + + public String text() { + return text; + } +} diff --git a/context-test/src/main/java/org/scijava/context/test/Shouter.java b/context-test/src/main/java/org/scijava/context/test/Shouter.java new file mode 100644 index 000000000..55205b820 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/Shouter.java @@ -0,0 +1,36 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +/** An extension type that plugins provide. */ +public interface Shouter { + + String shout(); +} diff --git a/context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java b/context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java new file mode 100644 index 000000000..a10d2f094 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test.impl; + +import java.util.ArrayList; +import java.util.List; + +import org.scijava.context.EventHandler; +import org.scijava.context.test.RecordingService; +import org.scijava.context.test.ShoutRecorded; + +/** + * A service whose event handler is private, in a package opened only + * to the container. + */ +public class DefaultRecordingService implements RecordingService { + + private final List recorded = new ArrayList<>(); + + @EventHandler + private void onShout(final ShoutRecorded event) { + recorded.add(event.text()); + } + + @Override + public List recorded() { + return recorded; + } +} diff --git a/context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java b/context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java new file mode 100644 index 000000000..58e6e79b5 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java @@ -0,0 +1,49 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test.impl; + +import org.scijava.context.test.GreeterService; + +/** + * A service implementation in a package that is neither exported nor opened. + *

+ * Callers can therefore never import it, cast to it, or call anything on it + * beyond {@link GreeterService} - while the container still discovers and + * constructs it, because {@code provides} grants + * {@link java.util.ServiceLoader} the access it needs. + *

+ */ +public class HiddenGreeter implements GreeterService { + + @Override + public String greet() { + return "hello from a hidden implementation"; + } +} diff --git a/context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java b/context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java new file mode 100644 index 000000000..61f4cfc44 --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java @@ -0,0 +1,66 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test.impl; + +import org.scijava.context.Context; +import org.scijava.context.Dependency; +import org.scijava.context.Plugin; +import org.scijava.context.test.GreeterService; +import org.scijava.context.test.Shouter; +import org.scijava.priority.Priority; + +/** + * A plugin with private dependency fields, in a package that is + * opened to the container but exported to nobody. + *

+ * Injecting these is deep reflection, so it works only because of the + * qualified {@code opens} in this module's descriptor - and it stays + * encapsulated, because {@code opens} is not {@code exports}. + *

+ */ +@Plugin(type = Shouter.class, name = "injected", priority = Priority.VERY_HIGH) +public class InjectedShouter implements Shouter { + + @Dependency + private GreeterService greeter; + + @Dependency + private Context context; + + @Override + public String shout() { + return greeter.greet().toUpperCase(); + } + + /** Exposed so the test can check the context was injected too. */ + public Context injectedContext() { + return context; + } +} diff --git a/context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java b/context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java new file mode 100644 index 000000000..15b9d4a2a --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java @@ -0,0 +1,53 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test.impl; + +import org.scijava.context.Attr; +import org.scijava.context.Plugin; +import org.scijava.context.test.Shouter; +import org.scijava.priority.Priority; + +/** A high-priority {@link Shouter}, in an unexported package. */ +@Plugin(type = Shouter.class, name = "loud", label = "Loud shouter", + priority = Priority.HIGH, attrs = { @Attr(name = "volume", value = "11") }) +public class LoudShouter implements Shouter { + + /** Records whether this class has ever been constructed. */ + public static volatile boolean constructed = false; + + public LoudShouter() { + constructed = true; + } + + @Override + public String shout() { + return "HELLO"; + } +} diff --git a/context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java b/context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java new file mode 100644 index 000000000..b16ba7f3b --- /dev/null +++ b/context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java @@ -0,0 +1,44 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test.impl; + +import org.scijava.context.Plugin; +import org.scijava.context.test.Shouter; +import org.scijava.priority.Priority; + +/** A low-priority {@link Shouter}. */ +@Plugin(type = Shouter.class, name = "quiet", priority = Priority.LOW) +public class QuietShouter implements Shouter { + + @Override + public String shout() { + return "hello"; + } +} diff --git a/context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java b/context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java new file mode 100644 index 000000000..04cd3cbdb --- /dev/null +++ b/context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java @@ -0,0 +1,79 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Context; + +/** + * Tests that a service's {@code @EventHandler} methods are subscribed for it, + * across a real module boundary and with the handler private. + * + * @author Curtis Rueden + */ +public class EventHandlerTest { + + /** + * The convenience that matters: a service author annotates a method and + * writes no subscription or cleanup code at all. + */ + @Test + public void testServiceHandlerNeedsNoCleanupCode() { + final Context context = Context.create(); + final RecordingService recorder = context.service(RecordingService.class); + + context.events().publish(new ShoutRecorded("one")); + context.events().publish(new ShoutRecorded("two")); + assertEquals(List.of("one", "two"), recorder.recorded()); + + // Disposing the context ends the subscription; the author wrote nothing. + context.dispose(); + context.events().publish(new ShoutRecorded("three")); + assertEquals(List.of("one", "two"), recorder.recorded()); + } + + /** Subscribing a private method needs no export, only the container's opens. */ + @Test + public void testHandlersStayEncapsulated() { + try (final Context context = Context.create()) { + context.service(RecordingService.class); + final Module module = RecordingService.class.getModule(); + assertTrue(module.isNamed(), "not running on the module path"); + assertTrue(module.isOpen("org.scijava.context.test.impl", // + Context.class.getModule())); + assertTrue(!module.isExported("org.scijava.context.test.impl")); + } + } +} diff --git a/context-test/src/test/java/org/scijava/context/test/InjectionTest.java b/context-test/src/test/java/org/scijava/context/test/InjectionTest.java new file mode 100644 index 000000000..9380d0195 --- /dev/null +++ b/context-test/src/test/java/org/scijava/context/test/InjectionTest.java @@ -0,0 +1,93 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Context; +import org.scijava.discovery.Discovery; + +/** + * Tests dependency injection across a real module boundary. + *

+ * Injecting a private field is deep reflection, so this passes only because + * the implementation package is opened to the container - and the package is + * still exported to nobody, so the encapsulation the plugin framework is + * supposed to provide is intact. + *

+ * + * @author Curtis Rueden + */ +public class InjectionTest { + + @Test + public void testPrivateFieldsAreInjectedAcrossModules() { + try (final Context context = Context.create()) { + final Discovery discovery = context.plugins(Shouter.class) // + .stream() // + .filter(d -> "injected".equals(d.attr("name").orElse(null))) // + .findFirst().orElseThrow(); + + final Shouter shouter = discovery.get(); + // The injected service is what makes this work. + assertEquals("HELLO FROM A HIDDEN IMPLEMENTATION", shouter.shout()); + } + } + + @Test + public void testTheContextInjectsItself() { + try (final Context context = Context.create()) { + final Shouter shouter = context.plugins(Shouter.class).stream() // + .filter(d -> "injected".equals(d.attr("name").orElse(null))) // + .findFirst().orElseThrow().get(); + + // NB: reached reflectively, since the class itself is not exported. + final Object injected = shouter.getClass().getMethod("injectedContext") + .invoke(shouter); + assertSame(context, injected); + } + catch (final ReflectiveOperationException exc) { + throw new AssertionError(exc); + } + } + + /** Injection must not require exporting anything. */ + @Test + public void testInjectionDoesNotRequireExporting() { + final Module module = Shouter.class.getModule(); + assertTrue(module.isNamed(), "not running on the module path"); + assertFalse(module.isExported("org.scijava.context.test.impl"), + "injection must not require the package to be exported"); + } +} diff --git a/context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java b/context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java new file mode 100644 index 000000000..fa6c9ab70 --- /dev/null +++ b/context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java @@ -0,0 +1,135 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Context; +import org.scijava.discovery.Discovery; + +/** + * Tests plugin discovery: the {@code @Plugin} annotation, the index the + * annotation processor writes at build time, and {@link Context#plugins}. + *

+ * This lives in its own module so the whole path is real - the annotation is + * processed by an actual build, and the implementations sit in a package that + * is opened to the container but exported to nobody. + *

+ * + * @author Curtis Rueden + */ +public class PluginDiscoveryTest { + + private static final String INJECTED = + "org.scijava.context.test.impl.InjectedShouter"; + private static final String LOUD = "org.scijava.context.test.impl.LoudShouter"; + private static final String QUIET = + "org.scijava.context.test.impl.QuietShouter"; + + /** Annotate a class, and it is found. No configuration file to write. */ + @Test + public void testPluginsAreDiscovered() { + try (final Context context = Context.create()) { + final List names = context.plugins(Shouter.class).stream() // + .map(Discovery::implClassName) // + .collect(Collectors.toList()); + // NB: highest priority first: VERY_HIGH, HIGH, LOW. + assertEquals(List.of(INJECTED, LOUD, QUIET), names); + } + } + + /** + * The point of an index: metadata is readable without loading the plugin + * class, which is what makes building a large menu cheap. + */ + @Test + public void testMetadataWithoutLoadingTheClass() { + try (final Context context = Context.create()) { + // NB: select by name rather than position, so that adding a plugin + // elsewhere in this module does not break this test. + final Discovery loud = context.plugins(Shouter.class).stream() // + .filter(d -> LOUD.equals(d.implClassName())) // + .findFirst().orElseThrow(); + + assertEquals(LOUD, loud.implClassName()); + assertEquals("loud", loud.attr("name").orElse(null)); + assertEquals("Loud shouter", loud.attr("label").orElse(null)); + assertEquals("11", loud.attr("volume").orElse(null)); + assertEquals(100.0, loud.priority()); + + // None of that constructed anything. + assertFalse(constructedLoudShouter(), + "reading metadata constructed the plugin"); + + // Asking for the object is what constructs it. + assertEquals("HELLO", loud.get().shout()); + assertTrue(constructedLoudShouter()); + } + } + + /** The implementations stay encapsulated: opened to the container only. */ + @Test + public void testImplementationsAreNotExported() { + final Module module = Shouter.class.getModule(); + assertTrue(module.isNamed(), "not running on the module path"); + assertFalse(module.isExported("org.scijava.context.test.impl"), + "the implementation package must not be exported"); + assertTrue(module.isOpen("org.scijava.context.test.impl", + Context.class.getModule()), "the container needs reflective access"); + } + + /** An unindexed type yields nothing rather than failing. */ + @Test + public void testUnknownPluginType() { + try (final Context context = Context.create()) { + assertTrue(context.plugins(Runnable.class).isEmpty()); + } + } + + /** + * NB: read through the module system rather than touching the class, since + * referring to the static field would itself load it. + */ + private static boolean constructedLoudShouter() { + try { + final Class c = Class.forName(LOUD); + return c.getField("constructed").getBoolean(null); + } + catch (final ReflectiveOperationException exc) { + throw new AssertionError(exc); + } + } +} diff --git a/context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java b/context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java new file mode 100644 index 000000000..670c3ec64 --- /dev/null +++ b/context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java @@ -0,0 +1,103 @@ +/*- + * #%L + * Integration tests for the scijava-context library. + * %% + * Copyright (C) 2021 - 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.context.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Context; + +/** + * Tests that a {@link Context} discovers services declared with + * {@code provides}, including ones whose package is neither exported nor + * opened. + *

+ * This lives in its own module because that is the only way to exercise the + * claim: a module must really declare {@code provides} for + * {@link java.util.ServiceLoader} to see its implementation, and the + * implementation package must really be unexported for encapsulation to mean + * anything. + *

+ * + * @author Curtis Rueden + */ +public class ServiceDiscoveryTest { + + @Test + public void testDiscoveryFindsTheService() { + try (final Context context = Context.create()) { + final GreeterService greeter = context.service(GreeterService.class); + assertNotNull(greeter); + assertEquals("hello from a hidden implementation", greeter.greet()); + } + } + + /** + * The implementation stays encapsulated: callers cannot reach its type, + * only the interface it provides. A {@code provides} declaration grants + * ServiceLoader the access it needs without exporting or opening anything. + */ + @Test + public void testImplementationStaysEncapsulated() { + try (final Context context = Context.create()) { + final GreeterService greeter = context.service(GreeterService.class); + final Class implClass = greeter.getClass(); + final Module module = implClass.getModule(); + + // NB: this test is only meaningful on the module path, so assert + // that rather than skipping quietly if the build stops being modular. + assertTrue(module.isNamed(), "not running on the module path, so " + + "this test proves nothing about encapsulation"); + + final String implPackage = implClass.getPackageName(); + assertEquals("org.scijava.context.test.impl", implPackage); + assertFalse(module.isExported(implPackage), + "the implementation package must not be exported"); + assertFalse(module.isOpen(implPackage), + "the implementation package must not be opened"); + assertTrue(module.isExported("org.scijava.context.test"), + "the service interface must be exported"); + } + } + + /** Each context gets its own instance of a discovered service. */ + @Test + public void testEachContextGetsItsOwnInstance() { + try (final Context first = Context.create(); + final Context second = Context.create()) + { + assertFalse(first.service(GreeterService.class) == second.service( + GreeterService.class), "two contexts shared one service instance"); + } + } +} diff --git a/context/.factorypath b/context/.factorypath new file mode 100644 index 000000000..7d8d9414d --- /dev/null +++ b/context/.factorypath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/context/pom.xml b/context/pom.xml new file mode 100644 index 000000000..f5c717262 --- /dev/null +++ b/context/pom.xml @@ -0,0 +1,161 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-context + 1.0.0-SNAPSHOT + + SciJava Context + An application container: services, discovered and wired. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.context + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + + + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-index + ${scijava-index.version} + + + org.scijava + scijava-events + ${scijava-events.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-spi + ${scijava-spi.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/context/src/main/java/module-info.java b/context/src/main/java/module-info.java new file mode 100644 index 000000000..a08794e35 --- /dev/null +++ b/context/src/main/java/module-info.java @@ -0,0 +1,42 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.context { + + exports org.scijava.context; + + requires transitive org.scijava.discovery; + requires transitive org.scijava.events; + requires org.scijava.index; + requires transitive org.scijava.spi; + requires org.scijava.priority; + + uses org.scijava.context.Service; + +} diff --git a/context/src/main/java/org/scijava/context/Access.java b/context/src/main/java/org/scijava/context/Access.java new file mode 100644 index 000000000..24ef38454 --- /dev/null +++ b/context/src/main/java/org/scijava/context/Access.java @@ -0,0 +1,101 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; + +/** + * Reflective access to classes that have opened themselves to the container. + *

+ * A plugin author writes one line: + *

+ * + *
+ * opens com.example to org.scijava.context;
+ * 
+ *

+ * and every SciJava module that legitimately needs in - the container + * constructing a plugin, the execution layer reading its {@code @Parameter} + * fields, later the harvester invoking a callback - borrows access from here + * rather than each demanding an {@code opens} of its own. + *

+ *

+ * That works because reflective access is checked against the module that + * performs it, which is this one, rather than the module that asked. + * Being static costs nothing: what matters to the module system is where the + * code lives, not how it is called. + *

+ * + * @author Curtis Rueden + */ +public final class Access { + + private Access() { + // NB: prevent instantiation of utility class. + } + + /** + * Gets a lookup with private access to the given class. + *

+ * The result is scoped to that one class: its holder can read and write its + * fields and invoke its methods, and can do nothing with any other class. + *

+ *

+ * NB: {@link java.lang.invoke.MethodHandles.Lookup} is deliberately what + * comes back, rather than some narrower accessor of our own. It is the + * JDK's own token for delegated reflective access - the reason + * {@code privateLookupIn} exists - and it already covers fields, methods + * and constructors. A bespoke facade would have to be extended every time a + * caller needed a kind of access we had not anticipated, for no benefit + * beyond a shorter method name. + *

+ * + * @param type the class to be reflected into + * @return a lookup with private access to it + * @throws IllegalArgumentException if the class's package is not open to + * this module + */ + public static Lookup lookupIn(final Class type) { + try { + // NB: privateLookupIn also requires this module to *read* the target's + // module, which a container never declares -- plugins depend on it, + // not the reverse. Only a module's own code may add that edge, which + // is the other reason this lives here. + Access.class.getModule().addReads(type.getModule()); + return MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } + catch (final IllegalAccessException exc) { + throw new IllegalArgumentException("Cannot reflect into " + type + .getName() + ". Does its module declare `opens " + type + .getPackageName() + " to org.scijava.context;`?", exc); + } + } +} diff --git a/context/src/main/java/org/scijava/context/Attr.java b/context/src/main/java/org/scijava/context/Attr.java new file mode 100644 index 000000000..0a8ef266d --- /dev/null +++ b/context/src/main/java/org/scijava/context/Attr.java @@ -0,0 +1,53 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An arbitrary key/value pair attached to a {@link Plugin}. + *

+ * Attributes land in the index, so they can be read without loading the + * plugin class. + *

+ * + * @author Curtis Rueden + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.ANNOTATION_TYPE) +public @interface Attr { + + String name(); + + String value() default ""; +} diff --git a/context/src/main/java/org/scijava/context/Context.java b/context/src/main/java/org/scijava/context/Context.java new file mode 100644 index 000000000..e152629e8 --- /dev/null +++ b/context/src/main/java/org/scijava/context/Context.java @@ -0,0 +1,536 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.ServiceLoader.Provider; +import java.util.Set; +import java.util.stream.Collectors; + +import org.scijava.discovery.Discovery; +import org.scijava.events.EventBus; +import org.scijava.events.Subscription; +import org.scijava.index.IndexDiscoverer; +import org.scijava.spi.Disposable; + +/** + * An application container: a set of {@link Service}s, discovered and wired, + * with one {@link EventBus} of their own. + *

+ * Services are created lazily. Asking for one constructs it, initializes it, + * and constructs whatever it asks for in turn; a service nobody wants is never + * built. This differs from SciJava Common, which instantiated every service it + * could find when the context was created. + *

+ *

+ * Several contexts may exist at once and are wholly independent: separate + * services, separate event buses. Nothing here is static. + *

+ * + * @author Curtis Rueden + */ +public class Context implements Disposable, AutoCloseable { + + private final List> providers; + + /** Services by the type they were requested as. */ + private final Map, Service> byRequestedType = new LinkedHashMap<>(); + + /** Services in creation order, so that disposal can reverse it. */ + private final List created = new ArrayList<>(); + + /** Services given explicitly to {@link #of}, not yet matched to a type. */ + private final List explicit = new ArrayList<>(); + + /** Services already initialized, or in the middle of initializing. */ + private final Set initialized = Collections.newSetFromMap( + new IdentityHashMap<>()); + + private final EventBus events = EventBus.create(); + + /** Plugin discovery, from the annotation index. Built on first use. */ + private IndexDiscoverer pluginDiscoverer; + + private volatile boolean disposed; + + private Context(final List> providers) { + this.providers = providers; + } + + /** + * Creates a context over the services available to the runtime. + * + * @return the new context + */ + public static Context create() { + // NB: the lookup is on Service itself, which this module declares `uses` + // for. See Service's documentation for why it cannot be otherwise. + return new Context(ServiceLoader.load(Service.class).stream() // + .collect(Collectors.toList())); + } + + /** + * Creates a context containing exactly the given services, bypassing + * discovery. Useful for tests and for embedding. + * + * @param services the services this context will contain + * @return the new context + */ + public static Context of(final Service... services) { + final Context context = new Context(Collections.emptyList()); + // NB: register everything before initializing anything. A service may + // depend on one declared after it, and initialization is where + // dependencies are acquired. + context.explicit.addAll(List.of(services)); + return context; + } + + /** + * Gets the service of the given type, creating it if necessary. + * + * @param the service type + * @param type the interface (or class) the service provides + * @return the service + * @throws NoSuchServiceException if no service provides that type + */ + public S service(final Class type) { + return optionalService(type).orElseThrow( // + () -> new NoSuchServiceException(type)); + } + + /** + * Gets the service of the given type, creating it if necessary. + * + * @param the service type + * @param type the interface (or class) the service provides + * @return the service, or empty if none provides that type + */ + public Optional optionalService(final Class type) { + if (type == null) throw new NullPointerException("type"); + synchronized (this) { + checkNotDisposed(); + final Service existing = byRequestedType.get(type); + if (existing != null) { + // NB: this may be a service still in the middle of initializing, + // when two services depend on each other. See initialize below. + return Optional.of(type.cast(existing)); + } + final Optional service = findService(type); + service.ifPresent(s -> { + // NB: publish before initializing, so that two services depending + // on each other resolve instead of recursing. + byRequestedType.put(type, s); + inject(s); + // NB: a service's lifetime is the context's, so its handlers need + // no cleanup by the author: disposing the context closes the bus. + subscribe(s); + initialize(s); + // NB: record for disposal only once initialization has finished. + // A service's dependencies finish first, so they land earlier in + // this list, and disposing in reverse therefore tears down each + // service before the ones it depends on. Recording on the way in + // would order them exactly backwards. + if (!created.contains(s)) created.add(s); + }); + return service; + } + } + + /** + * Lists the plugins of the given type. + *

+ * The result describes what is available without loading any plugin class: + * each {@link Discovery} reports its implementation class name, its + * {@link Plugin} metadata and its priority, and loads or constructs the + * class only when asked to. Building a menu therefore costs no class + * loading at all. + *

+ *

+ * Plugins come back highest priority first. + *

+ * + * @param

the extension type + * @param type the type of extension wanted + * @return the plugins of that type, highest priority first + */ + public

List> plugins(final Class

type) { + if (type == null) throw new NullPointerException("type"); + synchronized (this) { + checkNotDisposed(); + if (pluginDiscoverer == null) pluginDiscoverer = createPluginDiscoverer(); + } + final List> plugins = new ArrayList<>(pluginDiscoverer + .discover(type)); + plugins.sort(Comparator.comparingDouble(Discovery

::priority).reversed()); + return plugins; + } + + /** + * Fills in the {@link Dependency}-annotated fields of the given object. + *

+ * Services and plugins the context creates are injected automatically; this + * is for objects it did not create. + *

+ * + * @param target the object whose dependencies to fill in + * @throws ServiceException if a required dependency is missing, or the + * target's package is not open to this module + */ + public void inject(final Object target) { + if (target == null) throw new NullPointerException("target"); + for (Class c = target.getClass(); c != null; c = c.getSuperclass()) { + for (final Field field : c.getDeclaredFields()) { + final Dependency dependency = field.getAnnotation(Dependency.class); + if (dependency == null) continue; + injectField(target, field, dependency); + } + } + } + + /** + * Subscribes the {@link EventHandler}-annotated methods of the given object + * to this context's {@link EventBus}. + *

+ * Services are subscribed automatically when the context creates them, and + * unsubscribed when it is disposed. This is for everything else: a plugin + * may be created and discarded many times in a session, so its handlers are + * the caller's to manage. + *

+ * + * @param target the object whose handlers to subscribe + * @return a subscription per handler; close them when the object is done + * @throws ServiceException if a handler is malformed, or the target's + * package is not open to this module + */ + public List subscribe(final Object target) { + if (target == null) throw new NullPointerException("target"); + final List subscriptions = new ArrayList<>(); + for (Class c = target.getClass(); c != null; c = c.getSuperclass()) { + for (final Method method : c.getDeclaredMethods()) { + final EventHandler handler = method.getAnnotation(EventHandler.class); + if (handler == null) continue; + subscriptions.add(subscribeHandler(target, method, handler)); + } + } + return subscriptions; + } + + /** Gets this context's event bus. */ + public EventBus events() { + return events; + } + + /** + * Gets the services initialized so far, in the order they finished + * initializing. A service that failed to initialize is not among them, and + * is not disposed. + */ + public synchronized List services() { + return List.copyOf(created); + } + + /** Gets whether this context has been disposed. */ + public synchronized boolean isDisposed() { + return disposed; + } + + /** + * Disposes every service, in reverse order of creation, and closes the + * event bus. + *

+ * Disposal is reversed so that a service can still reach its dependencies + * while shutting down. A service that throws on the way out does not + * prevent the others from being disposed. + *

+ */ + @Override + public void dispose() { + if (disposed) return; + final List toDispose; + synchronized (this) { + if (disposed) return; + disposed = true; + // NB: Snapshot and clear under the lock, but dispose the services + // themselves outside of it. A service's dispose() may block on + // another thread (e.g. a Swing UI's disposal round-tripping through + // the EDT); holding this context's lock for that entire time would + // risk deadlock against any other thread synchronizing on this + // context, such as one blocked inside optionalService(...). + toDispose = new ArrayList<>(created); + created.clear(); + byRequestedType.clear(); + } + final List failures = new ArrayList<>(); + for (int i = toDispose.size() - 1; i >= 0; i--) { + try { + toDispose.get(i).dispose(); + } + catch (final Throwable exc) { + failures.add(exc); + } + } + // NB: closing the bus is what frees services from arranging their own + // unsubscription. + events.close(); + if (!failures.isEmpty()) { + final ServiceException exc = new ServiceException(failures.size() + // + " service(s) failed to dispose", failures.get(0)); + for (int i = 1; i < failures.size(); i++) + exc.addSuppressed(failures.get(i)); + throw exc; + } + } + + @Override + public void close() { + dispose(); + } + + // -- Helper methods -- + + /** Fills in one annotated field. */ + private void injectField(final Object target, final Field field, + final Dependency dependency) + { + final Object value; + try { + value = resolve(field.getType()); + } + catch (final NoSuchServiceException exc) { + if (!dependency.required()) return; + throw new ServiceException("Cannot satisfy " + target.getClass() + .getName() + "." + field.getName() + ": no service of type " + field + .getType().getName(), exc); + } + try { + // NB: deep reflection, so the target's package must be open to this + // module. `opens` is not `exports`: the package stays invisible to + // ordinary callers. + field.setAccessible(true); + field.set(target, value); + } + catch (final RuntimeException | IllegalAccessException exc) { + throw new ServiceException("Cannot inject " + target.getClass() + .getName() + "." + field.getName() + // + ". Does its module declare `opens " + target.getClass() + .getPackageName() + " to org.scijava.context;`?", exc); + } + } + + /** Subscribes one annotated method. */ + private Subscription subscribeHandler(final Object target, + final Method method, final EventHandler handler) + { + final Class[] params = method.getParameterTypes(); + if (params.length != 1) { + throw new ServiceException("An @EventHandler takes exactly one" + // + " parameter, the event: " + method.getDeclaringClass().getName() + // + "." + method.getName() + " takes " + params.length); + } + try { + // NB: the same deep reflection, and the same `opens`, that + // @Dependency needs. + method.setAccessible(true); + } + catch (final RuntimeException exc) { + throw new ServiceException("Cannot subscribe " + method + .getDeclaringClass().getName() + "." + method.getName() + // + ". Does its module declare `opens " + method.getDeclaringClass() + .getPackageName() + " to org.scijava.context;`?", exc); + } + return subscribeTyped(params[0], handler.priority(), target, method); + } + + /** Captures the event type, so that the bus sees a typed subscription. */ + private Subscription subscribeTyped(final Class eventType, + final double priority, final Object target, final Method method) + { + return events.subscribe(eventType, priority, event -> { + try { + method.invoke(target, event); + } + catch (final IllegalAccessException exc) { + throw new ServiceException("Cannot invoke handler " + method + .getDeclaringClass().getName() + "." + method.getName(), exc); + } + catch (final InvocationTargetException exc) { + // NB: unwrap, so subscribers see what the handler actually threw + // rather than a reflection wrapper. + final Throwable cause = exc.getCause(); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Error) throw (Error) cause; + throw new ServiceException("Handler failed: " + method + .getDeclaringClass().getName() + "." + method.getName(), cause); + } + }); + } + + /** Resolves what to inject for a field of the given type. */ + private Object resolve(final Class type) { + if (type == Context.class) return this; + if (type == EventBus.class) return events; + if (Service.class.isAssignableFrom(type)) { + return service(type.asSubclass(Service.class)); + } + throw new NoSuchServiceException(type); + } + + /** + * Builds the index-backed plugin discoverer. + *

+ * NB: which type a plugin provides has to be answerable from the index + * alone, hence the function: reading {@code type()} from the recorded + * annotation loads the declared type, never the implementation. + *

+ */ + private IndexDiscoverer createPluginDiscoverer() { + return new IndexDiscoverer<>(Plugin.class, // + item -> item.annotation().type().getName(), // + item -> attrsOf(item.annotation()), // + item -> item.annotation().priority(), // + Thread.currentThread().getContextClassLoader(), // + this::instantiate); + } + + /** + * Constructs a plugin. + *

+ * NB: this must live in this module. Reflective construction is + * checked against the module that performs it, so a plugin package opened + * to this container cannot be constructed by the discovery or index module + * on our behalf - which is why {@link Plugin} tells users to open their + * packages to {@code org.scijava.context}. + *

+ */ + private Object instantiate(final Class type) { + final Object plugin; + try { + plugin = type.getDeclaredConstructor().newInstance(); + } + catch (final ReflectiveOperationException exc) { + throw new ServiceException("Cannot construct plugin: " + // + type.getName() + ". Does its module declare `opens " + type + .getPackageName() + " to org.scijava.context;`?", exc); + } + inject(plugin); + return plugin; + } + + /** Flattens a plugin's metadata into the discovery's attribute map. */ + private static Map attrsOf(final Plugin plugin) { + final Map attrs = new LinkedHashMap<>(); + if (!plugin.name().isEmpty()) attrs.put("name", plugin.name()); + if (!plugin.label().isEmpty()) attrs.put("label", plugin.label()); + if (!plugin.description().isEmpty()) { + attrs.put("description", plugin.description()); + } + for (final Attr attr : plugin.attrs()) + attrs.put(attr.name(), attr.value()); + return attrs; + } + + /** + * Initializes a service, unless that is already done or under way. + *

+ * NB: the guard is set before initializing, so that two services + * depending on each other resolve rather than recursing forever. The + * consequence is that a service caught in such a cycle may briefly observe + * a peer that has not finished initializing. Where that matters, look the + * peer up at point of use rather than holding it from + * {@link Service#initialize}. + *

+ *

+ * Cycles are resolvable here only because dependencies are acquired during + * initialization rather than in the constructor. Constructor injection + * could not resolve them at all. + *

+ */ + private void initialize(final Service service) { + if (!initialized.add(service)) return; + try { + service.initialize(this); + } + catch (final Throwable exc) { + throw new ServiceException("Cannot initialize service: " + // + service.getClass().getName(), exc); + } + } + + /** + * Finds the highest-priority service providing the given type, among those + * given explicitly and those discovered. + *

+ * NB: discovered candidates are constructed in order to compare their + * priorities, and all but the winner are discarded uninitialized. + * {@link Service} requires constructors to be free of side effects for + * exactly this reason. + *

+ */ + private Optional findService(final Class type) { + Service best = null; + for (final Service service : explicit) { + if (!type.isInstance(service)) continue; + if (best == null || service.priority() > best.priority()) best = service; + } + for (final Provider provider : providers) { + // NB: type() loads the class but does not construct it, so + // implementations of other types cost nothing here. + if (!type.isAssignableFrom(provider.type())) continue; + final Service candidate; + try { + candidate = provider.get(); + } + catch (final Throwable exc) { + throw new ServiceException("Cannot construct service: " + // + provider.type().getName(), exc); + } + if (best == null || candidate.priority() > best.priority()) best = + candidate; + } + return Optional.ofNullable(type.cast(best)); + } + + private void checkNotDisposed() { + if (disposed) { + throw new IllegalStateException("This context has been disposed"); + } + } +} diff --git a/context/src/main/java/org/scijava/context/Dependency.java b/context/src/main/java/org/scijava/context/Dependency.java new file mode 100644 index 000000000..5d0675aaa --- /dev/null +++ b/context/src/main/java/org/scijava/context/Dependency.java @@ -0,0 +1,86 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field to be filled in by the {@link Context}. + * + *
+ * public class MyService implements Service {
+ *
+ * 	@Dependency
+ * 	private LogService log;
+ * }
+ * 
+ *

+ * The context injects services it owns, plus the {@link Context} itself and + * its {@link org.scijava.events.EventBus}. Injection happens after + * construction and before {@link Service#initialize(Context)}, so a dependency + * is ready to use by the time initialization runs. + *

+ *

+ * NB: this is deliberately not called {@code @Parameter}, as it was in SciJava + * Common. There it meant two unrelated things - a dependency to inject, and an + * input to a module - and the second is now the business of + * {@code org.scijava.struct}. + *

+ *

JPMS

+ *

+ * Injecting a private field is deep reflection, so a modular project must open + * the package to the container: + *

+ * + *
+ * opens com.example.services to org.scijava.context;
+ * 
+ *

+ * A package that is opened but not exported stays invisible to ordinary + * callers: they cannot import these classes or cast to them. Only the + * container gets in. + *

+ * + * @author Curtis Rueden + * @see Context#inject(Object) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Dependency { + + /** + * Whether the context must have such a dependency. When false, a missing + * one leaves the field null rather than failing. + */ + boolean required() default true; +} diff --git a/context/src/main/java/org/scijava/context/EventHandler.java b/context/src/main/java/org/scijava/context/EventHandler.java new file mode 100644 index 000000000..83fea315d --- /dev/null +++ b/context/src/main/java/org/scijava/context/EventHandler.java @@ -0,0 +1,86 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.scijava.priority.Priority; + +/** + * Marks a method as an event handler, to be subscribed to the context's + * {@link org.scijava.events.EventBus}. + * + *
+ * public class MyService implements Service {
+ *
+ * 	@EventHandler
+ * 	private void onDataOpened(final DataOpenedEvent event) { ... }
+ * }
+ * 
+ *

+ * The method takes exactly one parameter: the type of event it wants, subtypes + * included. + *

+ *

Lifetime

+ *

+ * A {@link Service}'s handlers are subscribed automatically when the context + * creates it, and unsubscribed when the context is disposed - a service's + * lifetime being the context's, there is nothing for the author to clean up. + *

+ *

+ * Other objects are not subscribed automatically, because a plugin may be + * created and discarded many times over a session, and handlers that outlived + * their objects would pile up. Call {@link Context#subscribe(Object)} for + * those, and close the returned subscriptions when the object is finished + * with. + *

+ *

JPMS

+ *

+ * Invoking a non-public handler is deep reflection, so a modular project must + * open the package to the container - the same {@code opens} that + * {@link Dependency} needs, not an additional one. + *

+ * + * @author Curtis Rueden + * @see Context#subscribe(Object) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface EventHandler { + + /** + * Sorts this handler against others for the same event, highest first. See + * {@link Priority}. + */ + double priority() default Priority.NORMAL; +} diff --git a/context/src/main/java/org/scijava/context/NoSuchServiceException.java b/context/src/main/java/org/scijava/context/NoSuchServiceException.java new file mode 100644 index 000000000..a010e8c77 --- /dev/null +++ b/context/src/main/java/org/scijava/context/NoSuchServiceException.java @@ -0,0 +1,50 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +/** Thrown when a context has no service of the requested type. */ +public class NoSuchServiceException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient Class serviceType; + + public NoSuchServiceException(final Class serviceType) { + super("No service of type " + serviceType.getName() + // + ". Is its module on the module path, and does it declare" + // + " `provides org.scijava.context.Service with ...`?"); + this.serviceType = serviceType; + } + + /** Gets the service type that was requested. */ + public Class serviceType() { + return serviceType; + } +} diff --git a/context/src/main/java/org/scijava/context/Plugin.java b/context/src/main/java/org/scijava/context/Plugin.java new file mode 100644 index 000000000..9c7a4a637 --- /dev/null +++ b/context/src/main/java/org/scijava/context/Plugin.java @@ -0,0 +1,108 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.scijava.index.Indexable; +import org.scijava.priority.Priority; + +/** + * Declares a class as an extension of some type, discoverable by that type. + *

+ * Annotate a class and it is discoverable - no configuration file to write, no + * {@code module-info} clause to remember: + *

+ * + *
+ * @Plugin(type = Converter.class, priority = Priority.HIGH)
+ * public class MyConverter implements Converter { ... }
+ * 
+ *

+ * An annotation processor records these at build time, so the container can + * list what is available, and read each one's metadata, without loading a + * single plugin class. That is what keeps a menu of a thousand commands + * cheap to build. + *

+ *

When to use this, and when to use a service

+ *

+ * This is for the long tail: commands, converters, widgets, formats, tools - + * many implementations, contributed widely, of which a given session uses few. + * {@link Service}s are different: a small, framework-level set, each the one + * implementation of its interface, declared with {@code provides} in a + * {@code module-info}. A service is not a plugin. + *

+ *

JPMS

+ *

+ * The container constructs plugins reflectively, so a modular project must + * open the package containing them - to the container only: + *

+ * + *
+ * opens com.example.plugins to org.scijava.context;
+ * 
+ *

+ * NB: {@code opens} is not {@code exports}. The package stays invisible to + * ordinary callers at compile time - they cannot import the class, cast to it, + * or call anything beyond the type it provides - while the container gets the + * reflective access it needs. + *

+ * + * @author Curtis Rueden + * @see Context#plugins(Class) + */ +@Indexable +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Plugin { + + /** The type of extension this plugin provides. */ + Class type(); + + /** An identifier for this plugin, unique among those of its type. */ + String name() default ""; + + /** A human-readable label, for display. */ + String label() default ""; + + /** A longer human-readable description. */ + String description() default ""; + + /** + * Sorts plugins of the same type, highest first. See {@link Priority}. + */ + double priority() default Priority.NORMAL; + + /** Arbitrary additional metadata. */ + Attr[] attrs() default {}; +} diff --git a/context/src/main/java/org/scijava/context/Service.java b/context/src/main/java/org/scijava/context/Service.java new file mode 100644 index 000000000..4ff05ac1f --- /dev/null +++ b/context/src/main/java/org/scijava/context/Service.java @@ -0,0 +1,107 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import org.scijava.priority.Priority; +import org.scijava.spi.Disposable; + +/** + * A long-lived object owned by a {@link Context}, discoverable by the type it + * provides. + *

+ * Services are found with {@link java.util.ServiceLoader}, so a module + * contributing one declares it in its {@code module-info.java}: + *

+ * + *
+ * provides org.scijava.context.Service with com.example.MyLogService;
+ * 
+ *

+ * Note that the declared service type is always {@code Service} itself, never + * the interface the implementation provides. {@link java.util.ServiceLoader} + * resolves {@code uses} against the module of its caller, so a + * container cannot look up an arbitrary third-party interface on that module's + * behalf: it would have to declare {@code uses com.example.MyLogService}, + * which it cannot know. Declaring one base type that the container does + * {@code uses} is what makes discovery work under JPMS at all; the container + * then sorts implementations by the interfaces they implement. + *

+ *

+ * Because {@code provides} grants {@link java.util.ServiceLoader} the access + * it needs, the implementation's package need be neither exported nor opened: + * a service can stay entirely encapsulated. + *

+ *

Lifecycle

+ *

+ * The container constructs a service with its no-argument constructor, then + * calls {@link #initialize(Context)}. A constructor must do nothing + * else. Where several implementations provide the same interface the + * container constructs each to compare {@link #priority()}, and discards all + * but the winner without ever initializing them - so construction must be free + * of side effects. Everything real belongs in {@code initialize}, where the + * context is available to look up dependencies. + *

+ *

+ * {@link #dispose()} is called when the context is disposed, in reverse order + * of creation, so that a service is still able to use its dependencies while + * shutting down. + *

+ * + * @author Curtis Rueden + */ +public interface Service extends Disposable { + + /** + * Prepares this service for use. Dependencies are available here, via + * {@link Context#service(Class)}. + * + * @param context the context that owns this service + */ + default void initialize(final Context context) { + // NB: nothing to do by default. + } + + /** + * Gets this service's priority, used to choose between implementations of + * the same interface. See {@link Priority}. + */ + default double priority() { + return Priority.NORMAL; + } + + /** + * Releases anything this service holds. Called when the context is + * disposed. + */ + @Override + default void dispose() { + // NB: nothing to do by default. + } +} diff --git a/context/src/main/java/org/scijava/context/ServiceException.java b/context/src/main/java/org/scijava/context/ServiceException.java new file mode 100644 index 000000000..7f02fb53e --- /dev/null +++ b/context/src/main/java/org/scijava/context/ServiceException.java @@ -0,0 +1,44 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +/** Thrown when a service cannot be constructed or initialized. */ +public class ServiceException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ServiceException(final String message) { + super(message); + } + + public ServiceException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/context/src/test/java/org/scijava/context/ContextTest.java b/context/src/test/java/org/scijava/context/ContextTest.java new file mode 100644 index 000000000..4f4e821f3 --- /dev/null +++ b/context/src/test/java/org/scijava/context/ContextTest.java @@ -0,0 +1,507 @@ +/* + * #%L + * An application container: services, discovered and wired. + * %% + * Copyright (C) 2026 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.context; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.scijava.events.EventDeliveryException; +import org.scijava.events.Subscription; +import org.scijava.priority.Priority; + +/** + * Tests {@link Context}. + * + * @author Curtis Rueden + */ +public class ContextTest { + + // -- Test services -- + + public interface Greeter extends Service { + + String greet(); + } + + public interface Recorder extends Service { + + List log(); + } + + public static class DefaultRecorder implements Recorder { + + private final List log = new ArrayList<>(); + + @Override + public List log() { + return log; + } + + @Override + public void dispose() { + log.add("disposed recorder"); + } + } + + /** Depends on {@link Recorder}, acquired during initialization. */ + public static class DefaultGreeter implements Greeter { + + private Recorder recorder; + + @Override + public void initialize(final Context context) { + recorder = context.service(Recorder.class); + recorder.log().add("greeter initialized"); + } + + @Override + public String greet() { + return "hello"; + } + + @Override + public void dispose() { + // NB: dependencies must still work while shutting down. + recorder.log().add("disposed greeter"); + } + } + + @Test + public void testServiceLookupAndDependency() { + try (final Context context = Context.of(new DefaultGreeter(), + new DefaultRecorder())) + { + final Greeter greeter = context.service(Greeter.class); + assertEquals("hello", greeter.greet()); + assertTrue(context.service(Recorder.class).log().contains( + "greeter initialized")); + } + } + + /** The same instance comes back every time. */ + @Test + public void testServicesAreSingletonsPerContext() { + try (final Context context = Context.of(new DefaultRecorder())) { + assertSame(context.service(Recorder.class), context.service( + Recorder.class)); + } + } + + @Test + public void testMissingService() { + try (final Context context = Context.of()) { + final NoSuchServiceException exc = assertThrows( + NoSuchServiceException.class, () -> context.service(Greeter.class)); + assertEquals(Greeter.class, exc.serviceType()); + assertTrue(context.optionalService(Greeter.class).isEmpty()); + } + } + + /** Disposal runs in reverse creation order, so dependencies outlive users. */ + @Test + public void testDisposalOrder() { + final DefaultRecorder recorder = new DefaultRecorder(); + final Context context = Context.of(recorder, new DefaultGreeter()); + context.service(Greeter.class); + context.dispose(); + + final int greeterAt = recorder.log().indexOf("disposed greeter"); + final int recorderAt = recorder.log().indexOf("disposed recorder"); + assertTrue(greeterAt >= 0, "greeter was never disposed"); + assertTrue(recorderAt >= 0, "recorder was never disposed"); + assertTrue(greeterAt < recorderAt, + "a service was disposed before the one that depends on it"); + } + + @Test + public void testDisposeIsIdempotentAndBlocksLookups() { + final Context context = Context.of(new DefaultRecorder()); + context.dispose(); + assertTrue(context.isDisposed()); + // Disposing twice is harmless. + context.dispose(); + assertThrows(IllegalStateException.class, () -> context.service( + Recorder.class)); + } + + /** Two contexts share nothing: not services, not the event bus. */ + @Test + public void testContextsAreIsolated() { + try (final Context first = Context.of(new DefaultRecorder()); + final Context second = Context.of(new DefaultRecorder())) + { + assertNotSame(first.service(Recorder.class), second.service( + Recorder.class)); + assertNotSame(first.events(), second.events()); + + first.service(Recorder.class).log().add("only in first"); + assertFalse(second.service(Recorder.class).log().contains( + "only in first")); + } + } + + /** Disposing the context closes its bus, so services need no cleanup. */ + @Test + public void testDisposeClosesTheEventBus() { + final Context context = Context.of(new DefaultRecorder()); + final List heard = new ArrayList<>(); + context.events().subscribe(String.class, heard::add); + context.events().publish("before"); + context.dispose(); + context.events().publish("after"); + assertEquals(List.of("before"), heard); + } + + // -- Injection -- + + public interface Counter extends Service { + + int count(); + } + + public static class DefaultCounter implements Counter { + + @Override + public int count() { + return 42; + } + } + + public static class InjectedService implements Service { + + @Dependency + private Counter counter; + + @Dependency + private Context context; + + @Dependency(required = false) + private Greeter absent; + + int countViaDependency() { + return counter.count(); + } + } + + @Test + public void testFieldInjection() { + try (final Context context = Context.of(new InjectedService(), + new DefaultCounter())) + { + final InjectedService service = context.service(InjectedService.class); + assertEquals(42, service.countViaDependency()); + assertSame(context, service.context); + assertNull(service.absent, "an optional dependency should stay null"); + } + } + + /** Dependencies are in place before initialize runs. */ + public static class EarlyUser implements Service { + + @Dependency + private Counter counter; + + private int seenDuringInit = -1; + + @Override + public void initialize(final Context context) { + seenDuringInit = counter.count(); + } + } + + @Test + public void testInjectionHappensBeforeInitialize() { + try (final Context context = Context.of(new EarlyUser(), + new DefaultCounter())) + { + assertEquals(42, context.service(EarlyUser.class).seenDuringInit); + } + } + + public static class Demanding implements Service { + + @Dependency + private Greeter greeter; + } + + /** A missing required dependency names the field and the type. */ + @Test + public void testMissingRequiredDependency() { + try (final Context context = Context.of(new Demanding())) { + final ServiceException exc = assertThrows(ServiceException.class, // + () -> context.service(Demanding.class)); + assertTrue(exc.getMessage().contains("greeter"), exc.getMessage()); + assertTrue(exc.getMessage().contains("Greeter"), exc.getMessage()); + } + } + + // -- Event handlers -- + + public static class Listening implements Service { + + final List heard = new ArrayList<>(); + + @EventHandler + private void onString(final String event) { + heard.add(event); + } + + @EventHandler(priority = Priority.HIGH) + private void onStringFirst(final String event) { + heard.add("first:" + event); + } + } + + /** A service's handlers are subscribed for it, with no cleanup to write. */ + @Test + public void testServiceHandlersAreSubscribedAutomatically() { + final Context context = Context.of(new Listening()); + final Listening service = context.service(Listening.class); + context.events().publish("hello"); + // NB: higher priority first. + assertEquals(List.of("first:hello", "hello"), service.heard); + + // Disposing the context unsubscribes them. + context.dispose(); + assertEquals(2, service.heard.size()); + } + + public static class Manual { + + final List heard = new ArrayList<>(); + + @EventHandler + private void onString(final String event) { + heard.add(event); + } + } + + /** Objects the context did not create are subscribed on request. */ + @Test + public void testManualSubscription() { + try (final Context context = Context.of()) { + final Manual target = new Manual(); + // NB: not subscribed until asked. + context.events().publish("ignored"); + assertTrue(target.heard.isEmpty()); + + final List subscriptions = context.subscribe(target); + assertEquals(1, subscriptions.size()); + context.events().publish("heard"); + assertEquals(List.of("heard"), target.heard); + + // The caller owns these, and can end them early. + subscriptions.forEach(Subscription::close); + context.events().publish("after"); + assertEquals(List.of("heard"), target.heard); + } + } + + public static class Malformed implements Service { + + @EventHandler + private void twoParameters(final String a, final String b) { + // NB: not a valid handler. + } + } + + /** A malformed handler is reported, naming the method. */ + @Test + public void testMalformedHandler() { + try (final Context context = Context.of(new Malformed())) { + final ServiceException exc = assertThrows(ServiceException.class, // + () -> context.service(Malformed.class)); + assertTrue(exc.getMessage().contains("twoParameters"), exc.getMessage()); + } + } + + public static class Throwing implements Service { + + @EventHandler + private void onString(final String event) { + throw new IllegalStateException("handler blew up"); + } + } + + /** What a handler throws reaches the publisher, not a reflection wrapper. */ + @Test + public void testHandlerExceptionIsUnwrapped() { + try (final Context context = Context.of(new Throwing())) { + context.service(Throwing.class); + final EventDeliveryException exc = assertThrows( + EventDeliveryException.class, () -> context.events().publish("boom")); + assertInstanceOf(IllegalStateException.class, exc.getCause()); + assertEquals("handler blew up", exc.getCause().getMessage()); + } + } + + // -- Mutual dependency -- + + public interface Ping extends Service { + + Pong pong(); + } + + public interface Pong extends Service { + + Ping ping(); + } + + public static class DefaultPing implements Ping { + + private Pong pong; + + @Override + public void initialize(final Context context) { + pong = context.service(Pong.class); + } + + @Override + public Pong pong() { + return pong; + } + } + + public static class DefaultPong implements Pong { + + private Ping ping; + + @Override + public void initialize(final Context context) { + ping = context.service(Ping.class); + } + + @Override + public Ping ping() { + return ping; + } + } + + /** + * Two services may depend on each other. This resolves only because + * dependencies are acquired during initialization rather than in the + * constructor - constructor injection could not do it at all. + */ + @Test + public void testMutualDependencyResolves() { + try (final Context context = Context.of(new DefaultPing(), + new DefaultPong())) + { + final Ping ping = context.service(Ping.class); + final Pong pong = context.service(Pong.class); + assertSame(pong, ping.pong()); + assertSame(ping, pong.ping()); + } + } + + // -- Failure during initialization -- + + public interface Fragile extends Service {} + + public static class DefaultFragile implements Fragile { + + @Override + public void initialize(final Context context) { + throw new IllegalStateException("cannot start"); + } + } + + /** A service that fails to initialize says which one, and why. */ + @Test + public void testInitializationFailureIsReported() { + try (final Context context = Context.of(new DefaultFragile())) { + final ServiceException exc = assertThrows(ServiceException.class, // + () -> context.service(Fragile.class)); + assertTrue(exc.getMessage().contains("DefaultFragile"), exc.getMessage()); + assertEquals("cannot start", exc.getCause().getMessage()); + } + } + + // -- Priority -- + + public static class LowGreeter implements Greeter { + + @Override + public String greet() { + return "low"; + } + + @Override + public double priority() { + return Priority.LOW; + } + } + + public static class HighGreeter implements Greeter { + + @Override + public String greet() { + return "high"; + } + + @Override + public double priority() { + return Priority.HIGH; + } + } + + /** Where several implementations provide a type, the highest wins. */ + @Test + public void testHighestPriorityWins() { + try (final Context context = Context.of(new LowGreeter(), + new HighGreeter())) + { + assertEquals("high", context.service(Greeter.class).greet()); + } + // NB: declaration order must not decide it. + try (final Context context = Context.of(new HighGreeter(), + new LowGreeter())) + { + assertEquals("high", context.service(Greeter.class).greet()); + } + } + + @Test + public void testServiceIsFoundByItsInterface() { + try (final Context context = Context.of(new LowGreeter())) { + assertInstanceOf(LowGreeter.class, context.service(Greeter.class)); + } + } +} diff --git a/convert3/pom.xml b/convert3/pom.xml new file mode 100644 index 000000000..00f871a90 --- /dev/null +++ b/convert3/pom.xml @@ -0,0 +1,154 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-convert3 + + 3.0.0-SNAPSHOT + + SciJava Convert + Converting a value to the type something else wants. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.convert3 + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/convert3/src/main/java/module-info.java b/convert3/src/main/java/module-info.java new file mode 100644 index 000000000..e3d1f8382 --- /dev/null +++ b/convert3/src/main/java/module-info.java @@ -0,0 +1,51 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.convert3 { + + exports org.scijava.convert3; + + requires transitive org.scijava.common3; + requires org.scijava.priority; + + uses org.scijava.convert3.Converter; + + provides org.scijava.convert3.Converter with + org.scijava.convert3.CastConverter, + org.scijava.convert3.NumberConverter, + org.scijava.convert3.StringConverter, + org.scijava.convert3.ToStringConverter, + org.scijava.convert3.ArrayConverter, + org.scijava.convert3.CollectionConverter, + org.scijava.convert3.PathConverters.FileToPath, + org.scijava.convert3.PathConverters.PathToFile, + org.scijava.convert3.PathConverters.StringToPath, + org.scijava.convert3.ConstructorConverter; + +} diff --git a/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java b/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java new file mode 100644 index 000000000..e0e13244d --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java @@ -0,0 +1,107 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Array; +import java.lang.reflect.Type; +import java.util.Collection; + +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts to an array, converting each element on the way. + *

+ * A collection becomes an array, an array becomes an array of another + * component type, and a lone value becomes an array of one - which is what + * makes {@code "3"} usable where {@code int[]} is wanted, a thing scripts and + * command lines do constantly. + *

+ * + * @author Curtis Rueden + */ +public class ArrayConverter implements Converter { + + @Override + public Type sourceType() { + return Object.class; + } + + @Override + public Type destType() { + return Object[].class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + return source != null && Types.component(dest) != null; + } + + @Override + public Object convert(final Object source, final Type dest) { + final Type componentType = Types.component(dest); + final Class component = Types.raw(componentType); + if (component == null) return null; + + final Object[] elements = elements(source); + final Object array = Array.newInstance(component, elements.length); + final Converters converters = Converters.get(); + for (int i = 0; i < elements.length; i++) { + // NB: each element is converted in turn, so a List reaches an + // int[] without anything knowing about that pair specifically. + final Object element = converters.tryConvert(elements[i], componentType) + .orElse(null); + if (element == null && elements[i] != null) return null; + Array.set(array, i, element); + } + return array; + } + + @Override + public double priority() { + return Priority.HIGH; + } + + // -- Helper methods -- + + private static Object[] elements(final Object source) { + if (source instanceof Collection) { + return ((Collection) source).toArray(); + } + if (source.getClass().isArray()) { + final int length = Array.getLength(source); + final Object[] elements = new Object[length]; + for (int i = 0; i < length; i++) + elements[i] = Array.get(source, i); + return elements; + } + return new Object[] { source }; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/CastConverter.java b/convert3/src/main/java/org/scijava/convert3/CastConverter.java new file mode 100644 index 000000000..ec2f8f0aa --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/CastConverter.java @@ -0,0 +1,97 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; + +import org.scijava.common3.Classes; +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts a value that is already of the wanted type: it returns it. + *

+ * NB: first, and so the answer whenever no conversion is needed at all. That + * is the common case by far - most values arrive already right - and it costs + * one assignability check. + *

+ * + * @author Curtis Rueden + */ +public class CastConverter implements Converter { + + @Override + public Type sourceType() { + return Object.class; + } + + @Override + public Type destType() { + return Object.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + if (source == null || dest == null) return false; + final Class raw = Types.raw(dest); + if (raw == null) return false; + if (!Classes.box(raw).isInstance(source)) return false; + // NB: a collection is only "already the right type" if what it holds is + // too, and erasure means we cannot tell by looking at the type. A + // List handed to a List would otherwise be returned + // unchanged -- the right raw type full of the wrong things -- so the + // element-wise converter gets it instead, and returns it untouched when + // the elements do match. + return !(source instanceof Collection && isParameterized(dest)); + } + + /** Gets whether the type says anything about what it contains. */ + private static boolean isParameterized(final Type dest) { + if (!(dest instanceof ParameterizedType)) return false; + for (final Type argument : ((ParameterizedType) dest) + .getActualTypeArguments()) + { + if (argument != Object.class) return true; + } + return false; + } + + @Override + public Object convert(final Object source, final Type dest) { + return source; + } + + @Override + public double priority() { + return Priority.VERY_HIGH; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java b/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java new file mode 100644 index 000000000..558ff2f0d --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java @@ -0,0 +1,123 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Array; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts to a collection, converting each element on the way. + *

+ * The element type comes from the destination's type parameter where there is + * one, so a {@code List} given a list of strings arrives holding + * doubles rather than strings that will fail later. + *

+ * + * @author Curtis Rueden + */ +public class CollectionConverter implements Converter> { + + @Override + public Type sourceType() { + return Object.class; + } + + @Override + public Type destType() { + return Collection.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + if (source == null) return false; + final Class raw = Types.raw(dest); + if (raw == null || !Collection.class.isAssignableFrom(raw)) return false; + // NB: only the shapes we can actually instantiate. A caller wanting its + // own collection type can contribute a converter for it. + return raw.isAssignableFrom(ArrayList.class) || raw.isAssignableFrom( + LinkedHashSet.class); + } + + @Override + public Object convert(final Object source, final Type dest) { + final Class raw = Types.raw(dest); + final Type elementType = elementType(dest); + final Collection collection = Set.class.isAssignableFrom(raw) + ? new LinkedHashSet<>() : new ArrayList<>(); + + final Converters converters = Converters.get(); + for (final Object element : elements(source)) { + if (elementType == null || elementType == Object.class) { + collection.add(element); + continue; + } + final Object converted = converters.tryConvert(element, elementType) + .orElse(null); + if (converted == null && element != null) return null; + collection.add(converted); + } + return collection; + } + + @Override + public double priority() { + return Priority.HIGH; + } + + // -- Helper methods -- + + /** Gets what the collection holds, if the type says. */ + private static Type elementType(final Type dest) { + final Type[] params = Types.typeParamsOf(dest, Collection.class); + return params == null || params.length == 0 ? null : params[0]; + } + + private static List elements(final Object source) { + final List elements = new ArrayList<>(); + if (source instanceof Collection) { + elements.addAll((Collection) source); + } + else if (source.getClass().isArray()) { + final int length = Array.getLength(source); + for (int i = 0; i < length; i++) + elements.add(Array.get(source, i)); + } + else elements.add(source); + return elements; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java b/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java new file mode 100644 index 000000000..1c4d861e5 --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java @@ -0,0 +1,103 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; + +import org.scijava.common3.Classes; +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts by handing the value to a constructor of the wanted type. + *

+ * This is what turns a {@code String} into a {@code File}, a {@code URI} into + * whatever wraps one, and a value into any class written to wrap it. It is + * last, because it is a guess - a constructor taking the source type is not a + * promise that the result means the same thing. + *

+ * + * @author Curtis Rueden + */ +public class ConstructorConverter implements Converter { + + @Override + public Type sourceType() { + return Object.class; + } + + @Override + public Type destType() { + return Object.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + return source != null && constructor(source, dest) != null; + } + + @Override + public Object convert(final Object source, final Type dest) { + final Constructor ctor = constructor(source, dest); + if (ctor == null) return null; + try { + return ctor.newInstance(source); + } + catch (final ReflectiveOperationException | IllegalArgumentException exc) { + // NB: the constructor existed and refused the value; the caller sees + // this as "cannot convert", which is what it amounts to. + return null; + } + } + + @Override + public double priority() { + return Priority.VERY_LOW; + } + + // -- Helper methods -- + + /** Finds a public constructor of the destination taking the source. */ + private static Constructor constructor(final Object source, + final Type dest) + { + final Class raw = Types.raw(dest); + if (raw == null || raw.isInterface() || raw.isPrimitive() || // + Modifier.isAbstract(raw.getModifiers())) return null; + for (final Constructor ctor : raw.getConstructors()) { + if (ctor.getParameterCount() != 1) continue; + final Class param = Classes.box(ctor.getParameterTypes()[0]); + if (param.isInstance(source)) return ctor; + } + return null; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/ConversionException.java b/convert3/src/main/java/org/scijava/convert3/ConversionException.java new file mode 100644 index 000000000..0e38bb284 --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/ConversionException.java @@ -0,0 +1,59 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Type; + +import org.scijava.common3.Types; + +/** + * Thrown when a value cannot be turned into the type something else wants. + * + * @author Curtis Rueden + */ +public class ConversionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ConversionException(final String message) { + super(message); + } + + public ConversionException(final String message, final Throwable cause) { + super(message, cause); + } + + /** Creates an exception naming what could not be converted into what. */ + public static ConversionException of(final Object source, final Type dest) { + return new ConversionException("Cannot convert " + // + (source == null ? "null" : Types.name(source.getClass())) + " to " + // + Types.name(dest)); + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/Converter.java b/convert3/src/main/java/org/scijava/convert3/Converter.java new file mode 100644 index 000000000..f4c5100f9 --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/Converter.java @@ -0,0 +1,150 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Type; +import java.util.function.Function; + +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Turns a value of one type into a value of another. + *

+ * Contribute one through {@link java.util.ServiceLoader}: a module that + * {@code provides org.scijava.convert3.Converter} is enough, and no container + * is involved. That matters because conversion is needed below the + * container - binding an input to a parameter converts, and a script or a + * command line supplies strings for everything. + *

+ *

+ * NB: SciJava Common's {@code Converter} had eight {@code canConvert} + * overloads across {@code Object}/{@code Class}/{@code Type} and a + * {@code ConversionRequest} to carry the combinations. Here there is one + * question - can you turn this value into that type - and + * the types are declared, so the common case needs no code at all: + *

+ * + *
+ * Converter.of(String.class, Integer.class, Integer::valueOf)
+ * 
+ * + * @param the type converted from + * @param the type converted to + * @author Curtis Rueden + */ +public interface Converter { + + /** Gets the type this converter accepts. */ + Type sourceType(); + + /** Gets the type this converter produces. */ + Type destType(); + + /** + * Converts the given value. + * + * @param source the value to convert, never null + * @param dest the type wanted, which {@link #supports} has accepted + * @return the converted value, or null if this converter cannot after all + */ + Object convert(Object source, Type dest); + + /** + * Gets whether this converter can turn the given value into the given type. + *

+ * The default answers from the declared types: the value must be one this + * converter accepts, and what it produces must fit where the result is + * going. + *

+ */ + default boolean supports(final Object source, final Type dest) { + if (source == null || dest == null) return false; + return Types.isAssignable(source.getClass(), sourceType()) && // + Types.isAssignable(destType(), Types.raw(dest) == null ? dest // + : org.scijava.common3.Classes.box(Types.raw(dest))); + } + + /** Sorts converters, highest first. See {@link Priority}. */ + default double priority() { + return Priority.NORMAL; + } + + /** + * Creates a converter from a function. + * + * @param the type converted from + * @param the type converted to + * @param sourceType the type accepted + * @param destType the type produced + * @param function what does the converting + * @return the new converter + */ + static Converter of(final Class sourceType, + final Class destType, final Function function) + { + return of(sourceType, destType, function, Priority.NORMAL); + } + + /** Creates a converter from a function, at the given priority. */ + static Converter of(final Class sourceType, + final Class destType, final Function function, + final double priority) + { + return new Converter<>() { + + @Override + public Type sourceType() { + return sourceType; + } + + @Override + public Type destType() { + return destType; + } + + @Override + @SuppressWarnings("unchecked") + public Object convert(final Object source, final Type dest) { + return function.apply((I) source); + } + + @Override + public double priority() { + return priority; + } + + @Override + public String toString() { + return Types.name(sourceType) + " -> " + Types.name(destType); + } + }; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/Converters.java b/convert3/src/main/java/org/scijava/convert3/Converters.java new file mode 100644 index 000000000..3926b20f4 --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/Converters.java @@ -0,0 +1,166 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; + +import org.scijava.common3.Classes; +import org.scijava.common3.Types; + +/** + * Converts values to the types other code wants, using the + * {@link Converter}s available to the runtime. + *

+ * Converters come from {@link ServiceLoader}, so this needs no container and + * no configuration. For an embedded or tested setting where implicit discovery + * is unwanted, construct an instance with an explicit list instead. + *

+ * + * @author Curtis Rueden + */ +public class Converters { + + private static class DefaultHolder { + + static final Converters INSTANCE = new Converters(discoverConverters()); + } + + private final List> converters; + + /** Creates an instance that uses exactly the given converters. */ + public Converters(final List> converters) { + this.converters = new ArrayList<>(converters); + // NB: highest priority first, so the first supporting converter wins. + this.converters.sort(Comparator.comparingDouble( + (Converter c) -> c.priority()).reversed()); + } + + /** Gets the shared instance, backed by {@link ServiceLoader} discovery. */ + public static Converters get() { + return DefaultHolder.INSTANCE; + } + + /** + * Converts the given value to the given type. + * + * @param source the value to convert + * @param dest the type wanted + * @return the converted value + * @throws ConversionException if nothing can convert it + */ + public Object convert(final Object source, final Type dest) { + return tryConvert(source, dest).orElseThrow(() -> ConversionException.of( + source, dest)); + } + + /** + * Converts the given value to the given type. + * + * @param the type wanted + * @param source the value to convert + * @param dest the type wanted + * @return the converted value + * @throws ConversionException if nothing can convert it + */ + public T convert(final Object source, final Class dest) { + @SuppressWarnings("unchecked") + final T result = (T) convert(source, (Type) dest); + return result; + } + + /** + * Converts the given value, if anything can. + *

+ * NB: this is the form for a user interface, where a half-typed value is + * not an error but simply not a value yet. A caller that needs the + * conversion to happen should use {@link #convert} and let it throw. + *

+ * + * @param source the value to convert + * @param dest the type wanted + * @return the converted value, or empty if nothing can convert it, or if + * the conversion itself declined + */ + public Optional tryConvert(final Object source, final Type dest) { + if (dest == null) return Optional.empty(); + if (source == null) { + // NB: null converts to null, except for a primitive, which cannot + // hold one: there, the type's own zero is the nearest thing. + final Class raw = Types.raw(dest); + return raw != null && raw.isPrimitive() // + ? Optional.ofNullable(Classes.nullValue(raw)) : Optional.empty(); + } + for (final Converter converter : converters) { + if (!converter.supports(source, dest)) continue; + final Object result = converter.convert(source, dest); + if (result != null) return Optional.of(result); + // NB: a converter that accepted the value and then produced nothing - + // unparseable text, most often - lets the next one try. + } + return Optional.empty(); + } + + /** Converts the given value, if anything can. */ + public Optional tryConvert(final Object source, final Class dest) { + @SuppressWarnings("unchecked") + final Optional result = (Optional) tryConvert(source, (Type) dest); + return result; + } + + /** Gets whether the given value can be converted to the given type. */ + public boolean supports(final Object source, final Type dest) { + if (dest == null) return false; + if (source == null) { + final Class raw = Types.raw(dest); + return raw != null && raw.isPrimitive(); + } + return converters.stream().anyMatch(c -> c.supports(source, dest)); + } + + /** Gets the converters in use, highest priority first. */ + public List> converters() { + return List.copyOf(converters); + } + + // -- Helper methods -- + + private static List> discoverConverters() { + final List> found = new ArrayList<>(); + // NB: loaded here, in this module, because ServiceLoader resolves `uses` + // against the calling module. + ServiceLoader.load(Converter.class).forEach(found::add); + return found; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/NumberConverter.java b/convert3/src/main/java/org/scijava/convert3/NumberConverter.java new file mode 100644 index 000000000..0e80a8d6a --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/NumberConverter.java @@ -0,0 +1,108 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.scijava.common3.Classes; +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts a number to another kind of number. + *

+ * NB: narrowing is allowed and silent, as it is in Java itself: a + * {@code double} parameter given a {@code long} is the ordinary case, and a + * {@code long} parameter given a {@code double} rounds toward zero exactly as + * a cast would. SciJava Common spread this over eight converter classes, one + * per target type. + *

+ * + * @author Curtis Rueden + */ +public class NumberConverter implements Converter { + + @Override + public Type sourceType() { + return Number.class; + } + + @Override + public Type destType() { + return Number.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + if (!(source instanceof Number) || dest == null) return false; + final Class raw = Types.raw(dest); + return raw != null && isNumeric(Classes.box(raw)); + } + + @Override + public Object convert(final Object source, final Type dest) { + final Number number = (Number) source; + final Class type = Classes.box(Types.raw(dest)); + if (type == Byte.class) return number.byteValue(); + if (type == Short.class) return number.shortValue(); + if (type == Integer.class) return number.intValue(); + if (type == Long.class) return number.longValue(); + if (type == Float.class) return number.floatValue(); + if (type == Double.class) return number.doubleValue(); + if (type == BigInteger.class) return BigInteger.valueOf(number + .longValue()); + if (type == BigDecimal.class) return toBigDecimal(number); + return null; + } + + @Override + public double priority() { + return Priority.HIGH; + } + + // -- Helper methods -- + + private static boolean isNumeric(final Class type) { + return type == Byte.class || type == Short.class || type == Integer.class || + type == Long.class || type == Float.class || type == Double.class || + type == BigInteger.class || type == BigDecimal.class; + } + + private static BigDecimal toBigDecimal(final Number number) { + if (number instanceof BigDecimal) return (BigDecimal) number; + if (number instanceof BigInteger) return new BigDecimal( + (BigInteger) number); + // NB: through the string, so that 0.1 is 0.1 and not the double nearest + // to it. A BigDecimal parameter asked for exactness by its type. + return new BigDecimal(number.toString()); + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/PathConverters.java b/convert3/src/main/java/org/scijava/convert3/PathConverters.java new file mode 100644 index 000000000..2386adea6 --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/PathConverters.java @@ -0,0 +1,135 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.io.File; +import java.lang.reflect.Type; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts between the three ways Java names a file. + *

+ * NB: {@code String} to {@code File} would work through + * {@link ConstructorConverter} anyway, but {@code String} to {@code Path} has + * no constructor to find, and neither direction between {@code File} and + * {@code Path} does. They are the same thing said three ways, and code should + * not have to care which way a caller said it. + *

+ * + * @author Curtis Rueden + */ +public final class PathConverters { + + private PathConverters() { + // prevent instantiation of utility class + } + + /** Converts a file to a path. */ + public static class FileToPath implements Converter { + + @Override + public Type sourceType() { + return File.class; + } + + @Override + public Type destType() { + return Path.class; + } + + @Override + public Object convert(final Object source, final Type dest) { + return ((File) source).toPath(); + } + + @Override + public double priority() { + return Priority.HIGH; + } + } + + /** Converts a path to a file. */ + public static class PathToFile implements Converter { + + @Override + public Type sourceType() { + return Path.class; + } + + @Override + public Type destType() { + return File.class; + } + + @Override + public Object convert(final Object source, final Type dest) { + return ((Path) source).toFile(); + } + + @Override + public double priority() { + return Priority.HIGH; + } + } + + /** Converts text to a path. */ + public static class StringToPath implements Converter { + + @Override + public Type sourceType() { + return String.class; + } + + @Override + public Type destType() { + return Path.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + return source instanceof String && !((String) source).isEmpty() && // + Types.raw(dest) == Path.class; + } + + @Override + public Object convert(final Object source, final Type dest) { + return Paths.get((String) source); + } + + @Override + public double priority() { + return Priority.HIGH; + } + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/StringConverter.java b/convert3/src/main/java/org/scijava/convert3/StringConverter.java new file mode 100644 index 000000000..6ec771b1c --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/StringConverter.java @@ -0,0 +1,119 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.scijava.common3.Classes; +import org.scijava.common3.Types; + +/** + * Converts text to a value: a number, a character, a boolean, an enum + * constant. + *

+ * This is the converter a script, a command line and a text field all need, + * since each of them has only strings to offer. + *

+ *

+ * NB: text that is not a value of the wanted type converts to nothing, rather + * than to zero. A half-typed number is not a zero, and a caller that wants to + * know the difference gets to. + *

+ * + * @author Curtis Rueden + */ +public class StringConverter implements Converter { + + @Override + public Type sourceType() { + return String.class; + } + + @Override + public Type destType() { + return Object.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + if (!(source instanceof String) || dest == null) return false; + final Class raw = Types.raw(dest); + if (raw == null) return false; + final Class type = Classes.box(raw); + return type == Character.class || type == Boolean.class || type + .isEnum() || isNumeric(type); + } + + @Override + public Object convert(final Object source, final Type dest) { + final String text = (String) source; + final Class type = Classes.box(Types.raw(dest)); + if (type == Character.class) { + return text.isEmpty() ? null : text.charAt(0); + } + if (type == Boolean.class) { + if (text.equalsIgnoreCase("true")) return Boolean.TRUE; + if (text.equalsIgnoreCase("false")) return Boolean.FALSE; + return null; + } + if (type.isEnum()) { + try { + return Types.enumValue(text, type); + } + catch (final IllegalArgumentException exc) { + return null; // NB: no such constant + } + } + try { + if (type == Byte.class) return Byte.valueOf(text); + if (type == Short.class) return Short.valueOf(text); + if (type == Integer.class) return Integer.valueOf(text); + if (type == Long.class) return Long.valueOf(text); + if (type == Float.class) return Float.valueOf(text); + if (type == Double.class) return Double.valueOf(text); + if (type == BigInteger.class) return new BigInteger(text); + if (type == BigDecimal.class) return new BigDecimal(text); + } + catch (final NumberFormatException exc) { + return null; // NB: not a number yet, or not a number at all + } + return null; + } + + // -- Helper methods -- + + private static boolean isNumeric(final Class type) { + return type == Byte.class || type == Short.class || type == Integer.class || + type == Long.class || type == Float.class || type == Double.class || + type == BigInteger.class || type == BigDecimal.class; + } +} diff --git a/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java b/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java new file mode 100644 index 000000000..340ab2bc3 --- /dev/null +++ b/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java @@ -0,0 +1,72 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import java.lang.reflect.Type; + +import org.scijava.common3.Types; +import org.scijava.priority.Priority; + +/** + * Converts anything to text, with {@link Object#toString()}. + *

+ * NB: low priority, since it accepts everything. Anything more specific - + * formatting a number, naming a file - should outrank it. + *

+ * + * @author Curtis Rueden + */ +public class ToStringConverter implements Converter { + + @Override + public Type sourceType() { + return Object.class; + } + + @Override + public Type destType() { + return String.class; + } + + @Override + public boolean supports(final Object source, final Type dest) { + return source != null && Types.raw(dest) == String.class; + } + + @Override + public Object convert(final Object source, final Type dest) { + return source.toString(); + } + + @Override + public double priority() { + return Priority.LOW; + } +} diff --git a/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter b/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter new file mode 100644 index 000000000..c734bd51c --- /dev/null +++ b/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter @@ -0,0 +1,12 @@ +# NB: for the classpath. The module path reads module-info's `provides` +# instead, but a great deal of scientific Java still runs unmodularized. +org.scijava.convert3.CastConverter +org.scijava.convert3.NumberConverter +org.scijava.convert3.StringConverter +org.scijava.convert3.ToStringConverter +org.scijava.convert3.ArrayConverter +org.scijava.convert3.CollectionConverter +org.scijava.convert3.PathConverters$FileToPath +org.scijava.convert3.PathConverters$PathToFile +org.scijava.convert3.PathConverters$StringToPath +org.scijava.convert3.ConstructorConverter diff --git a/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java b/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java new file mode 100644 index 000000000..fe560260f --- /dev/null +++ b/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java @@ -0,0 +1,222 @@ +/* + * #%L + * Converting a value to the type something else wants. + * %% + * Copyright (C) 2026 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.convert3; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.math.BigDecimal; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.scijava.common3.Types; + +/** + * Tests conversion. + * + * @author Curtis Rueden + */ +public class ConvertersTest { + + private final Converters converters = Converters.get(); + + /** The converters are found without a container of any kind. */ + @Test + public void testDiscovery() { + assertFalse(converters.converters().isEmpty(), + "converters should come from ServiceLoader"); + } + + /** A value already of the wanted type is returned, not rebuilt. */ + @Test + public void testNoConversionNeeded() { + final List list = List.of("a"); + assertSame(list, converters.convert(list, List.class)); + assertEquals("x", converters.convert("x", String.class)); + } + + /** A boxed value satisfies a primitive parameter, and the reverse. */ + @Test + public void testPrimitives() { + assertEquals(5, converters.convert(5, int.class)); + assertEquals(5.0, converters.convert(5, double.class)); + assertEquals(5L, converters.convert(5.9, long.class), "narrows as a cast"); + assertEquals(true, converters.convert(true, boolean.class)); + } + + /** Text becomes whatever kind of value was asked for. */ + @Test + public void testFromString() { + assertEquals(42, converters.convert("42", int.class)); + assertEquals(4.5, converters.convert("4.5", double.class)); + assertEquals('a', converters.convert("abc", char.class)); + assertEquals(true, converters.convert("TRUE", boolean.class)); + assertEquals(Season.WINTER, converters.convert("WINTER", Season.class)); + assertEquals(new BigDecimal("0.1"), converters.convert("0.1", + BigDecimal.class)); + } + + /** A BigDecimal from a double keeps what the text said, not the binary. */ + @Test + public void testExactness() { + assertEquals(new BigDecimal("0.1"), converters.convert(0.1, + BigDecimal.class)); + } + + /** Text that is not a value of that type converts to nothing. */ + @Test + public void testUnparseableText() { + assertEquals(Optional.empty(), converters.tryConvert("-", int.class)); + assertEquals(Optional.empty(), converters.tryConvert("", int.class)); + assertEquals(Optional.empty(), converters.tryConvert("SPRINGISH", + Season.class)); + assertThrows(ConversionException.class, () -> converters.convert("nope", + int.class)); + } + + /** Anything can be described as text. */ + @Test + public void testToString() { + assertEquals("42", converters.convert(42, String.class)); + assertEquals("WINTER", converters.convert(Season.WINTER, String.class)); + } + + /** A lone value becomes an array of one, which is what scripts pass. */ + @Test + public void testArrays() { + assertArrayEquals(new int[] { 3 }, (int[]) converters.convert("3", + int[].class)); + assertArrayEquals(new double[] { 1, 2, 3 }, (double[]) converters.convert( + List.of("1", "2", "3"), double[].class), 0); + assertArrayEquals(new String[] { "1", "2" }, (String[]) converters.convert( + new int[] { 1, 2 }, String[].class)); + } + + /** A collection's element type is honored, not merely its raw type. */ + @Test + public void testCollections() { + // NB: the element type comes from the destination type, so this is + // List rather than a raw List. + final List list = (List) converters.convert(new String[] { "1", "2" }, + Types.parameterize(List.class, Integer.class)); + assertEquals(List.of(1, 2), list); + + final Set set = (Set) converters.convert(List.of("1", "1", "2"), + Types.parameterize(Set.class, Integer.class)); + assertEquals(Set.of(1, 2), set); + } + + /** + * A collection of the wrong element type is converted, not waved through. + *

+ * NB: erasure makes {@code List} and {@code List} the same + * raw type, so a cast would return the right kind of container full of the + * wrong things - which is the sort of bug that surfaces much later, as a + * ClassCastException somewhere else entirely. + *

+ */ + @Test + public void testCollectionElementsAreConverted() { + final List converted = (List) converters.convert(List.of("1", "2"), + Types.parameterize(List.class, Integer.class)); + + assertEquals(List.of(1, 2), converted); + + // and a list that is already right comes back equal + assertEquals(List.of(1, 2), converters.convert(List.of(1, 2), Types + .parameterize(List.class, Integer.class))); + } + + /** The three ways of naming a file all convert to one another. */ + @Test + public void testFilesAndPaths() { + final File file = new File("/tmp/x.txt"); + assertEquals(file.toPath(), converters.convert(file, Path.class)); + assertEquals(file, converters.convert(file.toPath(), File.class)); + assertEquals(file, converters.convert("/tmp/x.txt", File.class)); + assertEquals(file.toPath(), converters.convert("/tmp/x.txt", Path.class)); + } + + /** A constructor taking the value is the last resort, and it works. */ + @Test + public void testWrappingConstructor() { + final Object wrapped = converters.convert("hello", Wrapper.class); + assertEquals("hello", ((Wrapper) wrapped).value); + } + + /** Null is null, except where a primitive cannot hold one. */ + @Test + public void testNull() { + assertEquals(Optional.empty(), converters.tryConvert(null, String.class)); + assertEquals(0, converters.convert(null, int.class)); + assertEquals(false, converters.convert(null, boolean.class)); + } + + /** What cannot be converted says so, rather than returning null. */ + @Test + public void testUnsupported() { + assertFalse(converters.supports(new Object(), Season.class)); + assertThrows(ConversionException.class, () -> converters.convert( + new Object(), Season.class)); + } + + /** An explicit list of converters is the form for a test or an embedder. */ + @Test + public void testExplicitConverters() { + final Converters only = new Converters(List.of( // + Converter.of(String.class, Season.class, s -> Season.SUMMER))); + assertEquals(Season.SUMMER, only.convert("anything", Season.class)); + assertTrue(only.tryConvert(42, String.class).isEmpty(), + "nothing else should be available"); + } + + // -- Test fixtures -- + + public enum Season { + WINTER, SPRING, SUMMER, AUTUMN + } + + public static class Wrapper { + + public final String value; + + public Wrapper(final String value) { + this.value = value; + } + } +} diff --git a/scijava-discovery-test/.gitignore b/discovery-test/.gitignore similarity index 100% rename from scijava-discovery-test/.gitignore rename to discovery-test/.gitignore diff --git a/scijava-discovery-test/LICENSE.txt b/discovery-test/LICENSE.txt similarity index 100% rename from scijava-discovery-test/LICENSE.txt rename to discovery-test/LICENSE.txt diff --git a/discovery-test/README.md b/discovery-test/README.md new file mode 100644 index 000000000..1b3e5db20 --- /dev/null +++ b/discovery-test/README.md @@ -0,0 +1,12 @@ +# SciJava Discovery Test: Integration tests for SciJava Discovery + +This module exercises `Discoverer` against Java's [`ServiceLoader`](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ServiceLoader.html) across real module boundaries. It is not intended to be used as a dependency. + +Its `module-info.java` `provides` sample implementations of `org.scijava.ops.spi.Op` and `org.scijava.ops.spi.OpCollection`, and its tests check that a `Discoverer` built with `Discoverer.usingProviders(c -> ServiceLoader.load(c).stream())` finds them. + +Note that `ServiceLoader` can only discover implementations of interfaces that the calling module declares it `uses`. This module therefore declares: + +```java +uses org.scijava.ops.spi.Op; +uses org.scijava.ops.spi.OpCollection; +``` diff --git a/scijava-discovery-test/pom.xml b/discovery-test/pom.xml similarity index 98% rename from scijava-discovery-test/pom.xml rename to discovery-test/pom.xml index ae8daba40..1dabf7ccb 100644 --- a/scijava-discovery-test/pom.xml +++ b/discovery-test/pom.xml @@ -92,6 +92,7 @@ 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT diff --git a/scijava-discovery-test/src/main/java/module-info.java b/discovery-test/src/main/java/module-info.java similarity index 100% rename from scijava-discovery-test/src/main/java/module-info.java rename to discovery-test/src/main/java/module-info.java diff --git a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java b/discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java similarity index 87% rename from scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java rename to discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java index ea6478c2a..8708c9c08 100644 --- a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java +++ b/discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java @@ -39,6 +39,16 @@ public class ServiceBasedAdder implements BiFunction, Op { + /** + * Whether this class has ever been constructed, so that tests can assert + * that discovery does not instantiate what it discovers. + */ + public static volatile boolean constructed = false; + + public ServiceBasedAdder() { + constructed = true; + } + @Override public Double apply(Number t, Number u) { return t.doubleValue() + u.doubleValue(); diff --git a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java b/discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java similarity index 100% rename from scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java rename to discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java diff --git a/scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op b/discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op similarity index 100% rename from scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op rename to discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.Op diff --git a/scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection b/discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection similarity index 100% rename from scijava-discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection rename to discovery-test/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection diff --git a/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java b/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java new file mode 100644 index 000000000..afb0ef9fd --- /dev/null +++ b/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java @@ -0,0 +1,83 @@ +/*- + * #%L + * Integration tests for the scijava-discovery library. + * %% + * Copyright (C) 2021 - 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.discovery.test; + +import java.util.List; +import java.util.ServiceLoader; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.scijava.discovery.Discoverer; +import org.scijava.discovery.Discovery; +import org.scijava.ops.spi.Op; + +/** + * Tests that {@link Discovery} reports what is available without constructing + * it - the property that lets metadata-only consumers, such as menu building, + * avoid paying for objects they will never use. + * + * @author Curtis Rueden + */ +public class DiscoveryLazinessTest { + + /** + * A {@link Discoverer} backed by {@link ServiceLoader} must report the + * implementation class name without constructing the implementation. + */ + @Test + public void testServiceLoaderDiscoveryIsLazy() { + // Note: Other tests in this JVM may already have constructed one. + ServiceBasedAdder.constructed = false; + + final Discoverer d = Discoverer.usingProviders( // + c -> ServiceLoader.load(c).stream()); + + final List> discoveries = d.discover(Op.class); + Assertions.assertEquals(1, discoveries.size()); + + final Discovery discovery = discoveries.get(0); + Assertions.assertEquals(ServiceBasedAdder.class.getName(), // + discovery.implClassName()); + Assertions.assertFalse(ServiceBasedAdder.constructed, + "Discovery must not construct the implementation"); + + // Only now should the object come into existence. + Assertions.assertNotNull(discovery.get()); + Assertions.assertTrue(ServiceBasedAdder.constructed); + } + + /** Discoveries of an unknown type yield nothing rather than failing. */ + @Test + public void testUnknownTypeYieldsNothing() { + final Discoverer d = Discoverer.usingProviders( // + c -> ServiceLoader.load(c).stream()); + Assertions.assertTrue(d.discover(Runnable.class).isEmpty()); + } +} diff --git a/scijava-discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java b/discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java similarity index 93% rename from scijava-discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java rename to discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java index 4451a4582..4a12c1ebd 100644 --- a/scijava-discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java +++ b/discovery-test/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java @@ -43,7 +43,7 @@ public class ServiceLoaderDiscovererTest { @Test public void testServiceLoaderWithBoth() { - Discoverer d = Discoverer.using(ServiceLoader::load); + Discoverer d = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()); assertDiscoveryRequirements(d, OpCollection.class, ServiceBasedMultipliers.class); assertDiscoveryRequirements(d, Op.class, ServiceBasedAdder.class); @@ -53,7 +53,7 @@ public void testServiceLoaderWithBoth() { private static void assertDiscoveryRequirements(Discoverer d, Class discovery, Class... impls) { - List> implementingClasses = d.discover(discovery).stream().map( + List> implementingClasses = d.instances(discovery).stream().map( o -> (Class) o.getClass()).collect(Collectors.toList()); for (Class cls : impls) Assertions.assertTrue(implementingClasses.contains(cls)); diff --git a/scijava-discovery/.gitignore b/discovery/.gitignore similarity index 100% rename from scijava-discovery/.gitignore rename to discovery/.gitignore diff --git a/scijava-discovery/LICENSE.txt b/discovery/LICENSE.txt similarity index 100% rename from scijava-discovery/LICENSE.txt rename to discovery/LICENSE.txt diff --git a/discovery/README.md b/discovery/README.md new file mode 100644 index 000000000..62569989c --- /dev/null +++ b/discovery/README.md @@ -0,0 +1,19 @@ +# SciJava Discovery: A library for the abstraction of service discovery mechanisms + +This module provides the `Discoverer` interface, an abstraction for service discovery: finding implementations of some type, by some mechanism - `ServiceLoader`, a generated annotation index, a YAML file, or a hand-curated list. + +## ` List> Discoverer.discover(Class c)` + +The one method a `Discoverer` must implement. It returns a list of `Discovery` descriptors for implementations of `c`. Descriptors, rather than constructed objects, are returned so that mechanisms able to report what is available without loading classes can do so. + +## ` List Discoverer.instances(Class c)` + +A convenience for callers that simply want the objects: it constructs an instance from each `Discovery` returned by `discover(c)`. + +## Creating `Discoverer`s + +Static factory methods on `Discoverer` cover the common cases: + +* `Discoverer.usingProviders(...)` wraps a function yielding `ServiceLoader.Provider`s, e.g. `Discoverer.usingProviders(c -> ServiceLoader.load(c).stream())`. +* `Discoverer.using(...)` wraps a function yielding already-constructed objects. +* `Discoverer.all()` returns every `Discoverer` provided via `ServiceLoader`. diff --git a/scijava-discovery/pom.xml b/discovery/pom.xml similarity index 90% rename from scijava-discovery/pom.xml rename to discovery/pom.xml index 4902d833b..389de7005 100644 --- a/scijava-discovery/pom.xml +++ b/discovery/pom.xml @@ -84,8 +84,19 @@ bsd_2 SciJava developers. + + + 1.0.1-SNAPSHOT + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + diff --git a/scijava-discovery/src/main/java/module-info.java b/discovery/src/main/java/module-info.java similarity index 97% rename from scijava-discovery/src/main/java/module-info.java rename to discovery/src/main/java/module-info.java index 2d09d2221..a9bd79618 100644 --- a/scijava-discovery/src/main/java/module-info.java +++ b/discovery/src/main/java/module-info.java @@ -31,6 +31,8 @@ exports org.scijava.discovery; + requires transitive org.scijava.priority; + uses org.scijava.discovery.Discoverer; } diff --git a/discovery/src/main/java/org/scijava/discovery/Discoverer.java b/discovery/src/main/java/org/scijava/discovery/Discoverer.java new file mode 100644 index 000000000..136122fc6 --- /dev/null +++ b/discovery/src/main/java/org/scijava/discovery/Discoverer.java @@ -0,0 +1,259 @@ +/*- + * #%L + * Plugin discovery subsystem for SciJava libraries. + * %% + * Copyright (C) 2021 - 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.discovery; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.ServiceConfigurationError; +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * A source of {@link Discovery discoveries}: implementations of some type, + * found by some mechanism - {@link ServiceLoader}, a generated annotation + * index, a YAML file, or a hand-curated list. + *

+ * {@link #discover} deliberately yields descriptors rather than + * constructed objects, so that mechanisms able to report what is available + * without loading classes can do so. Callers that simply want the objects can + * use {@link #instances}. + *

+ * + * @author Curtis Rueden + * @author Gabriel Selzer + */ +@FunctionalInterface +public interface Discoverer { + + /** + * Discovers implementations of the given type. + * + * @param c the type of interest + * @return descriptors of the implementations found + */ + List> discover(Class c); + + /** + * Discovers implementations of the given type, constructing each one. + * + * @param c the type of interest + * @return the implementations found + */ + default List instances(final Class c) { + return discover(c).stream() // + .map(Discovery::get) // + .collect(Collectors.toList()); + } + + /** + * Creates a lazy {@code Discoverer} from a function yielding + * {@link ServiceLoader.Provider}s, which report each implementation's class + * without constructing it. + *

+ * Callers pass {@code c -> ServiceLoader.load(c).stream()}. The function + * must be written in the calling module, because + * {@link ServiceLoader} resolves {@code uses} declarations against the + * module of its caller; a generic discovery module cannot perform the + * lookup on another module's behalf. + *

+ * + * @param func yields the providers of a given type + * @return a lazy discoverer wrapping that function + */ + static Discoverer usingProviders( + final Function, Stream>> func) + { + return new Discoverer() { + + @Override + @SuppressWarnings("unchecked") + public List> discover(final Class c) { + try { + return ((Stream>) (Stream) // + func.apply((Class) c)) // + .map(p -> Discovery.of(p.type(), p)) // + .collect(Collectors.toList()); + } + catch (ClassCastException | ServiceConfigurationError e) { + return Collections.emptyList(); + } + } + }; + } + + /** + * Creates a {@code Discoverer} from a function yielding already-constructed + * objects. Such a source cannot be lazy; prefer {@link ServiceLoader} + * or an index-backed discoverer where laziness matters. + * + * @param func yields the implementations of a given type + * @return a discoverer wrapping that function + */ + static Discoverer using( + final Function, ? extends Iterable> func) + { + return new Discoverer() { + + @Override + @SuppressWarnings("unchecked") + public List> discover(final Class c) { + try { + var itr = (Iterable) func.apply((Class) c); + return StreamSupport.stream(itr.spliterator(), false) // + .map(Discovery::of) // + .collect(Collectors.toList()); + } + catch (ClassCastException | ServiceConfigurationError e) { + return Collections.emptyList(); + } + } + }; + } + + /** + * Gets every {@code Discoverer} provided via {@link ServiceLoader}. + *

+ * This module declares {@code uses org.scijava.discovery.Discoverer}, so - + * unlike discovery of arbitrary types - it can perform this lookup itself. + *

+ * + * @return the provided discoverers + */ + static List all() { + try { + return ServiceLoader.load(Discoverer.class).stream() // + .map(ServiceLoader.Provider::get) // + .collect(Collectors.toList()); + } + catch (final ServiceConfigurationError e) { + return Collections.emptyList(); + } + } + + /** + * Gets every {@code Discoverer} provided via {@link ServiceLoader}, plus the + * given one. + * + * @param base the discoverer to append + * @return the provided discoverers, plus {@code base} + */ + static List all(final Discoverer base) { + final List discoverers = new ArrayList<>(all()); + discoverers.add(base); + return discoverers; + } + + /** + * Combines several discoverers into one. + * + * @param discoverers the discoverers to combine + * @return a discoverer yielding the discoveries of all of them + */ + static Discoverer union(final Iterable discoverers) { + return new Discoverer() { + + @Override + public List> discover(final Class c) { + final List> list = new ArrayList<>(); + for (var discoverer : discoverers) { + list.addAll(discoverer.discover(c)); + } + return list; + } + }; + } + + /** + * Restricts this discoverer to the given types. + * + * @param classes the types to allow + * @return the restricted discoverer + */ + default Discoverer onlyFor(final Class... classes) { + var list = Arrays.asList(classes); + var d = this; + return new Discoverer() { + + @Override + public List> discover(final Class c) { + if (list.contains(c)) return d.discover(c); + return Collections.emptyList(); + } + }; + } + + /** + * Blocks this discoverer from discovering the given types. + * + * @param classes the types to block + * @return the restricted discoverer + */ + default Discoverer except(final Class... classes) { + var list = Arrays.asList(classes); + var d = this; + return new Discoverer() { + + @Override + public List> discover(final Class c) { + if (list.contains(c)) return Collections.emptyList(); + return d.discover(c); + } + }; + } + + /** + * Discovers the greatest implementation of the given comparable type. + * + * @param c the type of interest + * @return the greatest implementation found, if any + */ + default > Optional discoverMax(final Class c) { + // NB: natural order sorts in ascending order + return instances(c).stream().max(Comparator.naturalOrder()); + } + + /** + * Discovers the least implementation of the given comparable type. + * + * @param c the type of interest + * @return the least implementation found, if any + */ + default > Optional discoverMin(final Class c) { + // NB: natural order sorts in ascending order + return instances(c).stream().min(Comparator.naturalOrder()); + } +} diff --git a/discovery/src/main/java/org/scijava/discovery/Discovery.java b/discovery/src/main/java/org/scijava/discovery/Discovery.java new file mode 100644 index 000000000..00c679fc5 --- /dev/null +++ b/discovery/src/main/java/org/scijava/discovery/Discovery.java @@ -0,0 +1,295 @@ +/*- + * #%L + * Plugin discovery subsystem for SciJava libraries. + * %% + * Copyright (C) 2021 - 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.discovery; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.scijava.priority.Priority; + +/** + * A single thing found by a {@link Discoverer}. + *

+ * A {@code Discovery} is a descriptor, not the object itself. This + * matters because the cheapest metadata sources - notably a generated + * annotation index - can report what is available, and what its attributes + * are, without loading any classes. Callers that only need to filter, + * sort or display the available implementations therefore need not pay for + * constructing them, and menu-style user interfaces can be built without + * loading the classes behind the menu items. + *

+ *

+ * The three access levels, in increasing order of cost: + *

+ *
    + *
  1. {@link #implClassName()} and {@link #attrs()} - no class loading.
  2. + *
  3. {@link #type()} - loads the class.
  4. + *
  5. {@link #get()} - instantiates the object.
  6. + *
+ * + * @param the type of the discovered object + * @author Curtis Rueden + */ +public interface Discovery { + + /** + * Gets the fully qualified name of the discovered implementation class. + * Implementations must be able to answer this without loading the + * class. + * + * @return the name of the implementation class + */ + String implClassName(); + + /** + * Gets the discovered implementation class, loading it if necessary. + * + * @return the implementation class + */ + Class type(); + + /** + * Gets the discovered object, constructing it if necessary. + * + * @return the discovered object + */ + T get(); + + /** + * Gets the metadata associated with this discovery - for example a menu + * path or a label. Implementations must be able to answer this + * without loading the implementation class. + * + * @return the metadata, which may be empty but is never {@code null} + */ + default Map attrs() { + return Collections.emptyMap(); + } + + /** + * Gets the value of a single metadata attribute. + * + * @param key the attribute name + * @return the attribute value, if present + */ + default Optional attr(final String key) { + return Optional.ofNullable(attrs().get(key)); + } + + /** + * Gets the priority of this discovery, used to sort discoveries without + * constructing them. See {@link Priority} for the standard constants. + * + * @return the priority + */ + default double priority() { + return Priority.NORMAL; + } + + /** + * Creates a {@code Discovery} wrapping an object that has already been + * constructed. + * + * @param instance the object + * @return a discovery of that object + */ + static Discovery of(final T instance) { + @SuppressWarnings("unchecked") + final Class type = (Class) instance.getClass(); + return of(type, () -> instance); + } + + /** + * Creates a {@code Discovery} of an already-loaded class, constructed on + * demand. + * + * @param type the implementation class + * @param supplier constructs the object + * @return a discovery of that class + */ + static Discovery of(final Class type, + final Supplier supplier) + { + return of(type, supplier, Collections.emptyMap(), Priority.NORMAL); + } + + /** + * Creates a {@code Discovery} of an already-loaded class, constructed on + * demand, with metadata. + * + * @param type the implementation class + * @param supplier constructs the object + * @param attrs the metadata + * @param priority the priority + * @return a discovery of that class + */ + static Discovery of(final Class type, + final Supplier supplier, final Map attrs, + final double priority) + { + return new Discovery<>() { + + @Override + public String implClassName() { + return type.getName(); + } + + @Override + public Class type() { + return type; + } + + @Override + public T get() { + return supplier.get(); + } + + @Override + public Map attrs() { + return attrs; + } + + @Override + public double priority() { + return priority; + } + + @Override + public String toString() { + return implClassName(); + } + }; + } + + /** + * Creates a {@code Discovery} of a class that has not been loaded yet. The + * class is loaded only when {@link #type()} or {@link #get()} is called, + * and constructed via its no-argument constructor. + *

+ * This is the factory for index-backed discovery mechanisms, where the + * whole point is to report what is available without loading it. + *

+ * + * @param implClassName the name of the implementation class + * @param superType the type the implementation class is assignable to + * @param classLoader the class loader with which to load the class + * @param attrs the metadata + * @param priority the priority + * @return a lazy discovery of that class + */ + static Discovery of(final String implClassName, + final Class superType, final ClassLoader classLoader, + final Map attrs, final double priority) + { + return of(implClassName, superType, classLoader, attrs, priority, null); + } + + /** + * Creates a {@code Discovery} of a class that has not been loaded yet, + * constructed by the given function. + *

+ * The function matters under JPMS: reflective construction is checked + * against the module that performs it, so a discovered class in a + * package opened only to some container cannot be constructed by this + * module on that container's behalf. A container therefore passes an + * instantiator of its own, defined in its own module, and users open their + * implementation packages to that container rather than to this one. + *

+ * + * @param implClassName the name of the implementation class + * @param superType the type the implementation class is assignable to + * @param classLoader the class loader with which to load the class + * @param attrs the metadata + * @param priority the priority + * @param instantiator constructs the object, or null to construct it here + * with the no-argument constructor + * @return a lazy discovery of that class + */ + static Discovery of(final String implClassName, + final Class superType, final ClassLoader classLoader, + final Map attrs, final double priority, + final Function, ? extends T> instantiator) + { + return new Discovery<>() { + + private Class type; + + @Override + public String implClassName() { + return implClassName; + } + + @Override + public synchronized Class type() { + if (type == null) { + try { + type = Class.forName(implClassName, false, classLoader) // + .asSubclass(superType); + } + catch (final ClassNotFoundException exc) { + throw new IllegalStateException( // + "Cannot load discovered class: " + implClassName, exc); + } + } + return type; + } + + @Override + public T get() { + if (instantiator != null) return instantiator.apply(type()); + try { + return type().getDeclaredConstructor().newInstance(); + } + catch (final ReflectiveOperationException exc) { + throw new IllegalStateException( // + "Cannot instantiate discovered class: " + implClassName, exc); + } + } + + @Override + public Map attrs() { + return attrs; + } + + @Override + public double priority() { + return priority; + } + + @Override + public String toString() { + return implClassName; + } + }; + } +} diff --git a/scijava-discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java b/discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java similarity index 92% rename from scijava-discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java rename to discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java index bf20ea136..558474e5c 100644 --- a/scijava-discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java +++ b/discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java @@ -29,7 +29,7 @@ package org.scijava.discovery; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -45,7 +45,7 @@ public class ManualDiscoverer implements Discoverer { private Set set; public ManualDiscoverer() { - set = new HashSet<>(); + set = new LinkedHashSet<>(); } public void register(Object[]... objects) { @@ -64,10 +64,10 @@ public void register(Object... objects) { @SuppressWarnings("unchecked") @Override - public List discover(Class c) { - return set.parallelStream() // + public List> discover(Class c) { + return set.stream() // .filter(o -> c.isAssignableFrom(o.getClass())) // - .map(o -> (T) o) // + .map(o -> Discovery.of((T) o)) // .collect(Collectors.toList()); } diff --git a/docs/conf.py b/docs/conf.py index e6315b705..5635c04c0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,6 +66,9 @@ ".DS_Store", "README.md", "examples/README.md", + "migration.md", + "migration-plan.md", + "versioning.md", ] # -- MyST-Parser/MyST-NB configuration --------------------------------------- diff --git a/docs/migration-plan.md b/docs/migration-plan.md new file mode 100644 index 000000000..23f437ac2 --- /dev/null +++ b/docs/migration-plan.md @@ -0,0 +1,1456 @@ +# Migrating SciJava Common into SciJava3 + +This document plans the migration of the remaining +[SciJava Common](https://github.com/scijava/scijava-common) (SJC) subsystems +into this repository (SciJava3, SJ3). + +Companion document: [migration.md](migration.md) — the class-by-class mapping +table from SJC to its SJ3 replacement (or to "dropped", or to a standard +library equivalent). + +## Goal + +**Deprecate all of SciJava Common. Port only what earns its place.** + +The deliverable is not a reimplementation of SJC. It is: + +1. a set of small, layered, dependency-light SJ3 modules covering the + functionality that is still worth having; +2. an `@deprecated` javadoc pointer on every SJC class, naming its replacement + — in SJ3, in the Java standard library, or nowhere; +3. a documented path by which each downstream component drops its + `scijava-common` dependency. + +## Guiding principles + +- **Every line of code is a liability.** If the Java standard library covers + it, recommend that instead and drop the class. A row in + [migration.md](migration.md) reading "use `java.util.X`" or "no replacement" + is a successful outcome, not a gap. +- **Classpath-first, module-path-clean.** Fiji runs on the classpath, so JPMS + strictness binds only those who deliberately modularize. Design so that + modularization is *possible* without contorting for it: no split packages, + resources under `META-INF/`, and a qualified `opens` to the container where + a module wants its implementations injected. Notably we give up **nothing** + here: implementation packages stay unexported and encapsulated, and + declarative field injection keeps working. See the JPMS section below for + the measurements behind this. +- **Leave SJC alone.** No functional changes, no Java version bump, no + delegation layer. Only `@deprecated` javadoc. +- **One-way migration.** Downstream components migrate by swapping the + dependency, updating imports, adjusting to API changes, and bumping their + major version. There is no two-way bridge. +- **Build bridges on demand.** Adapters between SJC and SJ3 are written when a + concrete need appears, not on a schedule. +- **Incarnational versioning.** The `3` in `common3` and `io3` is not a + workaround for a package collision; it is the component's *incarnation*. + Breaking changes are made by forking to a new incarnation with a new + artifactId and package prefix, so both incarnations can sit on one classpath + and downstream code keeps working. See [versioning.md](versioning.md). + - **A major version bump within one incarnation should never happen.** If + compatibility must break, bump the incarnation digit — artifactId, package + prefix and major version together — so the old one can stay loaded + alongside. + - Ideally the major version equals the incarnation number + (`scijava-common3` at `3.x.y`). Today it is at `1.x.y`, because the first + release of this reactor was a single monoversioned `1.0.0` big bang: + releasing a multi-module reactor piecemeal with heterogeneous versions + needs tooling support that `release-version.sh` does not yet have. The + realignment opportunity is the removal of the interfaces that moved to + `scijava-spi`, which breaks compatibility anyway and could land as `3.0.0`. + `scijava-io3` is unreleased, so it starts at `3.0.0-SNAPSHOT` already; + releasing a reactor with heterogeneous versions still awaits the + `release-version.sh` work. +- **Names are otherwise case by case.** Where an SJ3 package would collide + with an SJC one and no incarnation digit applies, pick a natural + collision-free noun. Every decision is recorded in + [migration.md](migration.md). + +## Hard constraints + +1. **SJ3 cannot reuse SJC package names.** Fiji will have both on the + classpath for years, and SJC's automatic module name is `org.scijava`, so + any shared package is a JPMS split-package error. Nothing new may live in + the root `org.scijava` package, nor in `org.scijava.{log, event, plugin, + service, convert, io, script, module, ui, widget, command, prefs, thread, + app, display, input, tool, menu, console, main, run, startup, text, + download, options, object, cache, parse, platform, task, annotations, + welcome}`. +2. **SJC targets Java 8 and will not be bumped.** It therefore cannot depend + on SJ3. Parity between an SJC class and its SJ3 replacement is *documented*, + not enforced by delegation, and SJC bugfixes do not flow forward. This is + acceptable for a component in wind-down. +3. **The compatibility components bridge the other way.** `scijava-compat` + (SJC commands usable from SJ3) and `scijava-ops-compat` (SJ3 ops usable + from SJC) are the only components in this repository permitted to depend + on `scijava-common`. +4. **Every component POM must pin the version of every SciJava artifact it + consumes, transitively included.** The modules here inherit from + `pom-scijava`, not from this repository's aggregator POM, so a version + property that is merely *absent* silently resolves the last *released* + artifact instead of the reactor's snapshot. Nothing enforces the "these + versions must match across all component POMs" convention, and the failure + is invisible until an API changes — at which point it surfaces as + `NoSuchMethodError` or `NoClassDefFoundError` at test time, far from its + cause. Six components were found in this state while moving interfaces into + `scijava-spi`. **An enforcer rule for this would pay for itself**; it is + listed under open decisions. + +## Current state + +These SJ3 modules already cover SJC subsystems. They do not need migrating — +they need the corresponding SJC classes deprecated with pointers. + +| SJ3 module | Supersedes in SJC | +| --- | --- | +| `scijava-common3` | `util.ClassUtils`, `util.GenericUtils`, `util.Types`, `util.AppUtils`, `util.PlatformUtils`, `util.DebugUtils`, `util.MersenneTwisterFast`, parts of `util.FileUtils` | +| `scijava-collections` | `util.*Array` | +| `scijava-meta` | `util.POM`, `util.XML`, `util.Manifest`, `util.VersionUtils` | +| `scijava-priority` | `Priority`, `Prioritized` | +| `scijava-progress` | `task` (roughly) | +| `scijava-struct` | the item model of `module` (`ModuleItem`, `ItemIO`) | +| `scijava-discovery` | the discovery role of `plugin` + `annotations` | +| `scijava-concurrent` | part of `thread` | +| SLF4J (used directly) | `log` | + +Remaining SJC usage inside this repository: + +- `scijava-compat` and `scijava-ops-compat` — intentional; they are the + bridges. +- `scijava-ops-image` / `scijava-ops-flim` tests — `io.location.FileLocation`, + imposed by SCIFIO's `ImgOpener` API. Gone once SCIFIO migrates; the utility + usages were removed in Phase 0. +- `scijava-ops-benchmarks` — three imports. + +## Cross-cutting design decisions + +### Discovery is two-tier, behind one API + +- **Services** are discovered via `ServiceLoader` + `module-info` / + `META-INF/services`. Services are a small, framework-level set written by + developers who can manage a `provides` line. This gives JPMS-correct + `uses`/`provides` wiring, and it breaks the bootstrap circularity in SJC + where `PluginService` is itself a plugin found by an index. +- **The long tail** — commands, converters, widgets, formats, tools — is + discovered via `@Plugin` and the generated index. This is where the + zero-configuration ergonomics matter, and where reading metadata *without + loading classes* matters (Fiji menu construction). +- `Service` does **not** extend `SciJavaPlugin`. + +Both tiers surface through one `Discoverer` API, so consumers never care which +mechanism found a thing. + +### `Discoverer` returns descriptors, not instances + +The current signature ` List discover(Class)` returns constructed +objects. That forces eager instantiation, provides no metadata without class +loading, and carries no priority, attributes or provenance. An index-backed +`Discoverer` under this signature would have to throw away the index's one +advantage. + +Change it to yield lazy descriptors: + +```java +interface Discovery { + String implClassName(); // no class loading + Map attrs(); // menu path, label, priority, ... + double priority(); + Class type(); // loads the class + T get(); // instantiates +} +``` + +All three mechanisms map cleanly: `ServiceLoader.stream()` supplies +`Provider::type` (loads without instantiating) and `Provider::get`; the +`@Plugin` index supplies `implClassName` and `attrs` with no loading at all; +op YAML files already have this shape. + +**A JPMS constraint discovered while implementing this:** `ServiceLoader` +resolves `uses` declarations against the module of its *caller*, so +`scijava-discovery` cannot perform a `ServiceLoader` lookup on another +module's behalf — doing so fails for every type that `org.scijava.discovery` +does not itself declare `uses` for. The lookup function must therefore be +supplied by the consuming module: + +```java +Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()) +``` + +The one exception is `Discoverer.all()`, which looks up `Discoverer` itself — +a type this module does declare `uses` for — and so can be a no-argument +convenience. This is a real constraint on any generic discovery facade under +JPMS, not an artifact of our design. + +**This refactor should land before `scijava-context` is built on top of +`Discoverer`.** It is mechanical now and expensive later. `ManualDiscoverer` +— hand-feeding implementations, which is valuable for tests, scripts and +embedding — is preserved. + +### Annotation processing keeps `@Plugin` viable, with guards + +`@Plugin` is retained: adding one annotation and having everything work, +with no configuration files, has been of large value to the SciJava community. +The risks are in *downstream* builds, not ours: + +- Java 23+ no longer runs annotation processors found on the classpath + implicitly. `pom-scijava-base` sets this up for SciJava components, but a + hand-rolled Maven or Gradle build silently produces no index, and the plugin + simply never appears. + +Mitigations to build: + +1. Generate the index from a Maven plugin **as well as** from the annotation + processor, so a build that misses annotation processing still works. +2. Emit a loud runtime diagnostic when a `@Plugin`-annotated class is absent + from the index, naming the exact fix. This converts a silent failure into a + one-line answer. + +`annotations.EclipseHelper` is **not** ported: modern m2e `.factorypath` +generation covers it, and the directory-indexing capability belongs in the +Maven plugin (under a name that does not say "Eclipse", since it runs +elsewhere too). + +### JPMS specifics + +These were measured, not reasoned about, with a three-module test project +(`javac --module-source-path`, run on the module path, JDK 21): + +| Situation | Result | +| --- | --- | +| Index resources (`META-INF/json/...`) read via `ClassLoader.getResources` | **works** — `META-INF` is not a valid package name, so it is never encapsulated | +| `ServiceLoader` finding a provider declared with `provides`, in a package that is neither exported nor opened | **works** — the `provides` declaration itself grants the access | +| Reflective `newInstance` on a public class in a package that is neither exported nor opened | **fails**: `IllegalAccessException` | +| The same, with a qualified `opens to ` | **works** | +| `setAccessible` injection into a **private** field, with that qualified `opens` | **works** | +| An ordinary module importing a class from that opened-but-unexported package | **compile error**: "package ... is declared in module ..., which does not export it" | +| A service with a **private** constructor plus a `public static provider()` factory | **works** — `ServiceLoader` prefers the factory | +| Reflective construction of a class in a package opened **to the container**, performed by a *different* module (e.g. a shared discovery library) | **fails**: `IllegalAccessException` — the check is against the module that performs it | + +The load-bearing conclusion is the second-to-last row: **`opens` is not +`exports`.** A qualified `opens` grants the container deep reflective access at +runtime while leaving the package invisible to ordinary callers at compile +time. Implementations therefore stay encapsulated — callers cannot import +them, cannot cast to them, and cannot call their non-API methods — and the +container can still construct and inject them. + +That last row is easy to design past and costly to discover late: it is not +enough to know *that* reflection needs an `opens`, one must know **which +module the `opens` names**. Generic discovery code cannot construct a plugin on +a container's behalf, so the container passes an instantiator defined in its +own module, and users open their packages to the container. `Discovery.of` and +`IndexDiscoverer` therefore take an optional instantiator. + +So the SJ3 context keeps **declarative field injection**, as SciJava Common +has always had. The cost to a plugin module is one line: + +```java +opens my.plugins.impl to org.scijava.context; +``` + +### Cancellation is thread interruption + +SciJava has invented several cancellation APIs over the years — `Cancelable` +in SJC's `module` package and across ImageJ Ops, the cancel half of +`task.Task`, and Appose's `cancel()`. SciJava Ops deliberately shipped without +any of them. The replacement is the platform's own mechanism, not a fourth +invention. + +- **Mechanism:** thread interruption. Every interruptible JDK blocking call + already honors it — `Thread.sleep`, `Object.wait`, `BlockingQueue`, + `Lock.lockInterruptibly`, NIO channels — and Java 21's + `StructuredTaskScope` cancels its subtasks by interrupting them, so this + stays current. +- **Client API:** `ExecutorService.submit()` → `Future.cancel(true)`. That is + what a caller holds: cancellation, completion and result in one object every + Java developer already knows. +- **No `Cancelable`, and no cancellation token parameter.** Every scheme is + cooperative, so that is not the differentiator; composition is. An ambient + thread flag is honored by code we did not write, while an `isCanceled()` + interface only works for code that knows about it, and a token pollutes + every signature while still being unable to abort a JDK blocking call. + +**An interrupt carries no reason.** `Thread.interrupt()` takes no argument and +the JDK constructs `InterruptedException` with a null message, so a reason +cannot ride along. A reason belongs to the requesting side — the `Future`, or +the caller's own record of why it cancelled — not to the exception. + +#### Cooperating in code that cannot throw + +Ops are `Function`, `Computers.Arity*` and similar, whose methods declare no +checked exceptions, so an op cannot throw `InterruptedException`. The idiom: + +```java +if (Thread.interrupted()) { // NB: this clears the flag... + Thread.currentThread().interrupt(); // ...so restore it + throw new CancellationException("op cancelled"); +} +``` + +`java.util.concurrent.CancellationException` is unchecked and is what +`Future.get()` already throws for a cancelled task, so callers see one type. + +**`Progress.update()` is the checkpoint.** Ops that do long work already call +it periodically, so making it check for interruption gives cancellation with +no new API, no extra parameter, and no per-op work. Progress and cancellation +are both ambient and thread-scoped; they belong on the same rail. An op that +reports no progress is not cancellable, which is honest, and a nudge toward +reporting progress. + +#### Known limits, accepted deliberately + +- `CompletableFuture.cancel()` does **not** interrupt the running thread; it + only completes the future exceptionally. Expose `ExecutorService` futures + where cancellation must reach a compute loop. +- The interrupt flag is thread-bound and **does not inherit**, unlike + `Progress`, which is an `InheritableThreadLocal`. Propagation to workers is + therefore deliberate. In `TaskExecutors` most of it was already there: + `ExecutorService.invokeAll` cancels its unfinished tasks — interrupting + them — when the awaiting thread is interrupted. What was missing was the + other half: the `InterruptedException` was wrapped in a plain + `RuntimeException` and **the caller's own flag was never restored**, so + cancellation died at that layer. It now restores the flag and throws + `CancellationException`. +- Plain `InputStream`/`FileInputStream` reads are not interruptible; only NIO + channels are. Blocking I/O therefore aborts between operations, not during + one. +- Native code ignores interrupts: a long OpenCV call cannot be cancelled + mid-call. +- The common practical hazard is library code that catches + `InterruptedException` and swallows it. The rule is always: propagate it, or + restore the flag. + +#### Across a process boundary + +Interruption cannot cross one, so **Appose's explicit `cancel()` is correct as +it stands**. In-process: interruption. Across processes: an explicit protocol. + +#### Consequences for this migration + +1. `Cancelable` is **not ported** (see [migration.md](migration.md)). +2. The Phase 3 execution layer hands back a `Future`; module and op execution + submit to an executor, and cancellation is `future.cancel(true)`. +3. `Progress.update()` checks for interruption and throws + `CancellationException`. +4. `Parallelization` propagates cancellation to its workers: `TaskExecutors` + restores the interrupt flag and throws `CancellationException` rather than + swallowing the interrupt in a `RuntimeException`. +5. `DataHandles.copy` checks once per block and throws + `java.io.InterruptedIOException`, which is an `IOException` — so it fits + the existing signature — and whose `bytesTransferred` field reports the + partial copy. The granularity is per-block, identical to the + `task.isCanceled()` check it replaces. + +### Logging + +SLF4J, used directly, as `scijava-types` and `scijava-ops-engine` already do. +`log` is not ported. An SJC `LogService` → SLF4J bridge is built if needed. + +## Phases + +### Phase 0 — Groundwork + +- Remove SJC usage where an SJ3 replacement already exists: switch the + `ops-image` / `ops-flim` tests to `common3.MersenneTwisterFast` and + `collections.LongArray`, replace `ListUtils`. **Done.** Note that the + remaining `io.location.FileLocation` usage in the `ops-image` and `ops-flim` + tests is imposed by SCIFIO's `ImgOpener` API, so it cannot go away until + SCIFIO migrates; `scijava-ops-tutorial` turned out to have no SJC usage. +- Add `@deprecated` javadoc pointers in SJC for everything already superseded + (see the table above). +- Start [migration.md](migration.md), scripting the mapping where possible. + Note that `common3.Types` is not a superset of `util.Types`: `box`, `field`, + `isBoolean` and friends moved to `Classes`, and `enumFromString` / `args` + were dropped. +- Refactor `Discoverer` to the `Discovery` descriptor API (see above). + **Done.** + +### Phase 1 — Standalone leaves + +No application context required by anything in this phase. + +- **`scijava-spi`** (new): pure interfaces, no behavior, no dependencies. + **Done**, with `Versioned`, `GenericTyped`, `Named`, `Identifiable` and + `Disposable`. `Versioned` and `GenericTyped` moved here from `common3` and + were **not** left behind as deprecated aliases, which the repository's + current adoption level permits. + - The test for membership: *if a class can implement it without knowing + about any SciJava framework, it is SPI.* `Cancelable` fails that test — + and is not ported at all, since cancellation is thread interruption — as + do `Validated`, `BasicDetails`, + `UIDetails` and `Plugin` (metadata bags for the plugin/module layer) and + `Initializable` (lifecycle, so it belongs with `scijava-context`). + - `Named` exposes `String name()` only. Mutability is a separate contract; + a default `setName` that throws `UnsupportedOperationException` forces + every caller to feature-detect. + - **Deferred, pending a consumer:** `Locatable`, whose only real content was + a default implementation that needs `common3.Classes.location` — which + this module cannot depend on; and `Typed`, which has no consumer until the + plugin layer exists, and whose `Class type()` would collide awkwardly + with `GenericTyped.type()` returning a `Type`. Both are cheap to add when + something needs them; neither is cheap to remove once published. +- **`scijava-common3`** absorbs the remaining general utilities: `ArrayUtils`, + `StringUtils`, `Bytes`, `DigestUtils`, `NumberUtils`, `ProcessUtils`, + `ListUtils`, `UnitUtils`, and the non-URL parts of `FileUtils`. It stays + dependency-free. (`Timing` is on hold — unclear downstream usage.) +- **`scijava-index`** (new, `org.scijava.index`): ports `annotations` — the + annotation processor and index reader — plus `IndexDiscoverer`, a + `Discoverer` backed by the index. **Done.** This is what supplies plugin + metadata without loading classes, and it is what the `Discovery` refactor + was for: a test proves that discovery never asks the class loader for an + implementation class, using a recording class loader rather than a static + flag, since reading such a flag would itself load the class. + - The index format (`META-INF/json/`) is deliberately unchanged + from SJC, so an index written by either side is readable by the other. + - Which type an indexed item provides must be answerable from metadata, + so `IndexDiscoverer` takes a function for it; for an annotation shaped + like `@Plugin(type = Service.class)` that is + `item -> item.annotation().type().getName()`, which loads the *declared* + type but never the implementation. + - The component sets `maven.compiler.proc` to `none` for itself: javac + reads `META-INF/services` before the processor class it names exists. + SJC has the same constraint, and solves it the same way. + - Still to do: the loud runtime diagnostic for an annotated class missing + from the index. It belongs with whichever component defines the + annotation, so it lands in Phase 2. +- **`scijava-io3`** (new, `org.scijava.io3.{location, handle, nio}`): ports + `io.location`, `io.handle`, `io.nio` and `ByteBank`. Locations are **done**; + handles are next. The external `scijava-io-http` folds in later as + `org.scijava.io3.http`. + - **This component stays standalone**: it is broadly useful on its own — to + Bio-Formats, for instance — so it depends on nothing but + `scijava-collections`, and resolvers are discovered with **plain + `ServiceLoader`** rather than `Discoverer` or a container. Nothing here + requires buying into a framework. + - That works because the `uses`-resolves-against-the-caller constraint only + bites a *generic* facade discovering arbitrary types on another module's + behalf. This component knows its own service type, so it declares + `uses org.scijava.io3.location.LocationResolver` in its own + `module-info.java` and is self-contained. + - `LocationResolver` carries its own `priority()` as a plain `double` + rather than depending on `scijava-priority`; the `Priority` constants are + compatible with it. `Locations` can also be constructed with an explicit + resolver list, for embedding and testing. + - Handles are **done** too, on the same pattern: `DataHandle` absorbs the + tiny `WrapperPlugin` contract (`locationType`, `supports`, `set`, `get`, + `priority`) instead of extending the plugin framework, and + `DataHandleService` becomes `DataHandles`. + - Note that discovered handles are **prototypes**: a handle carries the + position and buffers of one open stream, so `create()` instantiates a + fresh one per call. SciJava Common got this from `WrapperService`; here + it is explicit, and tested. + - `DataHandles.copy` took a `Task`, which carried both progress *and + cancellation*; its replacement `LongConsumer` carries only progress. + Cancellation is thread interruption instead — see the cancellation + section — so `copy` checks the flag once per block and throws + `InterruptedIOException`. + - From `io.nio`, `ByteBufferByteBank` **is** ported: a direct buffer is + off-heap, so its bytes reach native code and shared memory without a + copy — which matters increasingly for Appose's named shared memory + blocks. `NIOService` is dropped, being two JDK calls behind a + system-property switch; nothing in the ported architecture precludes + reintroducing `FileChannel` later, since a handle may open a channel and + a bank may be backed by a mapped buffer. This component is therefore + complete. +- **`scijava-events`** (new, `org.scijava.events`): a clean-room typed event + bus. **Done.** Not a port of the bushe fork, so the attribution obligation + is gone. The external `scijava-listeners` stays a separate concern: a + listener list is the right primitive for "this object notifies its + observers", and is not a bus. + - **Lambdas, not annotations.** `subscribe(Class, Consumer)` + is type-safe, needs no reflection, and so needs no `opens`. + `@EventHandler` ergonomics are not lost: `scijava-context` already + reflects over services, so it can scan annotated methods, register them as + lambdas, and hold the subscriptions. The convenience lives in the tier + that already pays for reflection. + - **Strong references plus an explicit `Subscription`.** SciJava Common held + subscribers weakly, which produced handlers that silently stopped firing + and forced a `WeakHashMap` named `keepEm` whose only job was stopping the + proxies from being collected too early — the implementation fighting its + own design. Cleanup is structural instead: `EventBus.close()` drops every + subscription, and a context calls it on dispose, so a service that + subscribes never arranges its own cleanup. + - **No shared instance, deliberately.** A bus is nothing but shared mutable + subscriber state, so an ambient singleton would let two application + contexts hear each other's events. Each context owns one bus. NB: this is + the opposite call from `Locations.get()` and `DataHandles.get()`, which + are safe to share precisely because they hold no such state. + - **Class-hierarchy dispatch** is retained — it is what makes a bus more + than a listener list — and any object may be an event, with no marker + interface, so a type from another library can be one. + - **Consumption is opt-in**, via a `Consumable` interface on the event, on + the model of AWT's `InputEvent.consume()`. Toolkit-style input events can + then stop propagation without every event paying for the concept. + - **Subscriber order** is by `scijava-priority`, descending, ties broken by + subscription order. It matters most for consumable events, where an + earlier subscriber gets first refusal. + - **A failing subscriber does not truncate delivery.** Everyone still + receives the event, and the failures are thrown together afterward as an + `EventDeliveryException` — first as cause, rest suppressed, none lost. + Cancellation is the exception: an interrupted subscriber, or one throwing + `CancellationException`, stops delivery at once, since an interrupt means + this thread is being cancelled and must not be buried among unrelated + failures. + - **`EventHistory` is dropped.** The real need behind it — a macro recorder + that tracks module executions so a user can review what they did — is + better served by an explicit layer later; note that SciJava Ops already + tracks execution provenance per output object (`OpHistory.executionsUpon`), + which answers "what produced this?" rather than "what happened?". +- **Key/value parsing moves to Parsington**, not into a SJ3 module. The whole + SJC `parse` package is 319 lines whose only non-Parsington dependency is + `ObjectArray` (replaceable with `ArrayList`), and Parsington already carries + an `eval` package and a CLI. This adds no dependencies there and deletes a + module from this plan. +- **Not ported:** `log` (SLF4J), `cache` (see Phase 2), build-time and + obsolete utilities (`Combiner`, `MetaInfCombiner`, `ServiceCombiner`, + `CheckSezpoz`, `MirrorWebsite`, `TunePlayer`, `ReflectedUniverse`, + `util.Prefs`, `ConversionUtils`), and the collection odds and ends + (`LastRecentlyUsed` → `LinkedHashMap` with `accessOrder`; `IteratorPlus` → + nothing; `SizableArrayList` → nothing, as its reflective resizing rationale + died with Java 17; `TreeNode` → on hold, only `imagej-common` uses it). + +### Phase 2 — The application context + +- **`scijava-context`** (new, `org.scijava.context`): services with a + lifecycle and dependency injection; plugin metadata from `scijava-index`; + instantiation via `Discoverer`; ordering via `scijava-priority`. Plus the + core services: object registry, prefs, thread, app/status. + - **The container core is done**: `Service`, `Context`, lazy creation, + priority selection, lifecycle, disposal, and one `EventBus` per context. + `scijava-context-test` is a sibling module — the only way to test the + claims that matter, since a module must really declare `provides`, and its + implementation package must really be unexported, for the test to mean + anything. It verifies that a service in a package that is neither exported + nor opened is still discovered and constructed. + - **Services are discovered as `Service`, never as their own interface.** + `ServiceLoader` resolves `uses` against its *caller's* module, so a + container cannot look up an arbitrary third-party interface on that + module's behalf. Declaring one base type that this module `uses` is what + makes discovery work under JPMS at all; the container then sorts + implementations by the interfaces they implement. `Provider.type()` keeps + that cheap, since implementations of other types are never constructed. + - **Two services may depend on each other**, because dependencies are + acquired in `initialize` rather than in the constructor — a cycle that + constructor injection could not resolve at all. The cost is that a service + in a cycle may briefly see a peer that has not finished initializing. + - **Disposal reverses the order in which services finished initializing**, + not the order they were requested, so a service is always torn down before + the ones it depends on. + - **The plugin tier is done**: `@Plugin` (indexable, with `@Attr`) and + `Context.plugins(Class)`, which returns `Discovery` descriptors from the + annotation index — class name, metadata and priority, with nothing loaded + until asked. Tested through a real build in `scijava-context-test`: + annotated classes in an unexported package, indexed by the annotation + processor at compile time, discovered with their metadata, and constructed + only on demand. + - **A downstream project must put the processor on the annotation processor + path** (as the Ops modules do for `scijava-ops-indexer`). javac will not + find it on the module path, and says nothing when it finds no processor at + all: the build simply produces no index and the plugins silently fail to + appear. This is the hazard noted above, met first-hand. `pom-scijava` + configuring this for SciJava components would remove the trap for the + people most likely to fall into it. + - **Field injection is done**: `@Dependency` fills in services, the + `Context` and the `EventBus`, after construction and before + `Service.initialize`. It is deliberately not called `@Parameter` as in + SciJava Common, where that annotation meant two unrelated things — a + dependency to inject, and an input to a module — the second of which is + now `org.scijava.struct`'s business. + `scijava-context-test` injects **private** fields of a plugin across a + real module boundary, proving both halves of the earlier claim at once: + the qualified `opens` grants the container deep reflection, and the + package is still exported to nobody. + - **`@EventHandler` scanning is done**, and it is what the two-tier design + was protecting: the event bus itself stays reflection-free, while the + container — which already reflects — turns annotated methods into + subscriptions. A service's handlers are subscribed when the context + creates it and dropped when the context is disposed, so a service author + writes no subscription or cleanup code at all. + - Objects the context did **not** create are not subscribed automatically: + a plugin may be created and discarded many times in a session, and + handlers outliving their objects would pile up. `Context.subscribe(Object)` + returns the subscriptions for the caller to close. + - What a handler throws reaches the publisher unwrapped, rather than as an + `InvocationTargetException`. + - Still to do here: the core services themselves. The one-way legacy + bridge is built, as `scijava-compat`; see below. + - **Dependencies are not constructor arguments.** Constructor injection + would publish every dependency in a signature, so changing an internal + dependency would be an API change and a binary compatibility break unless + old constructors were kept forever. It also cannot work for the + `ServiceLoader` tier, which requires a public no-arg constructor or a + static `provider()` factory. Dependencies are instead acquired after + construction — declaratively, by annotated field, as SciJava Common does — + which keeps them an implementation detail. + - Services may therefore have a **private** constructor and a + `public static provider()` factory, exposing no construction API at all. + - Deliberately a much smaller surface than SJC's `Context` + + `PluginService` + `PluginInfo` + the handler/wrapper/typed-service + hierarchy. Keep what is used; drop the rest. + - **Keep the class name `Context`.** The scripting layer controls the + parameter alias table, so `#@ Context ctx` can resolve to whichever class + we choose; script compatibility does not force a rename, and the decision + can be made late. +- **`scijava-convert3`** (name TBD): ported, not replaced by Ops. Implicit + parameter conversion and `getCompatibleInputs` are foundational to the + execution layer and the input harvester — `imagej-legacy`'s + `DatasetToImagePlusConverter` and friends are the proof. A `Converter` SPI + plus a convert service discovered via `Discoverer`, with **one** adapter + exposing Ops conversions as converters. Ops matching-based conversion stays + a distinct mechanism joined by that adapter, not merged. +- **`scijava-cache`** moves into this repository, keeping the + `org.scijava.cache` package, with its SJC dependency repointed to + `scijava-context`. It never reached 1.0.0, so there is no SemVer concern. +- **One-way bridge only:** built as `scijava-compat`, which presents an SJC + context's modules as SJ3 commands (see *The real risk is migration*, below). + Still to do: preprocessors that inject legacy objects into existing + scripts. Nothing is added to SJC. +- Fiji startup can then construct the SJ3 `Context` instead of the SJC one. + +### Phase 3 — Execution layer + +- **Factoring the member parsing out of `scijava-ops-engine`** is wanted, but + the coupling is deeper than the file names suggest. Inspecting the nineteen + classes in `ops.engine.struct`, only a handful are framework-neutral + (`FieldInstance`, `ParameterData`, `FunctionalMethodType`, + `SynthesizedParameterMember`, `RetypingRequest`). The parsers themselves + reach into Op-specific exceptions (`FunctionalTypeOpException`, + `NullablesOnMultipleMethodsException`), Op-specific annotations + (`@Nullable`, `@OpDependency` in `scijava-ops-spi`), and `ops.engine.util` + helpers. Lifting them means first deciding where an "optional parameter" + annotation lives, and `scijava-ops-spi` is already released. + - So: build the execution layer first and let a **second real consumer** + show which abstractions are genuinely shared, rather than guessing from + the Ops side alone. That is the same discipline applied to `ObjectService` + and the settings store. + - The execution layer must **not** depend on `ops-engine`; if a dependency + is useful it runs the other way. +- **`scijava-execute`** (`org.scijava.execute`): pre/postprocessor chains, + input resolution, a `@Parameter` equivalent, and a runner — built on + `scijava-struct`. Cancellation needs nothing of its own: a run is a + `Callable` for an executor, and cancelling the `Future` interrupts it. + - **Started**: `@Parameter` on fields, a `MemberParser` that reads them + (inherited ones included), and `Executables.run`, which binds inputs by + name and returns outputs by name. + - `@Parameter` here means only what it meant in SciJava Common's *module* + sense — an input or output of something runnable. Injecting a service is + `@Dependency`, in `scijava-context`. Conflating those two is what made the + original confusing. + - **A required input must be supplied, not merely non-null.** Checking the + field instead would let a missing required `double` run silently as `0.0`, + since a primitive field is never null. + - **The processor chain is done**: `Preprocessor` and `Postprocessor` + plugins, and `Runner.run`, which is always asynchronous and returns + `Future`. One verb covers both cases, since a caller + wanting to block writes `run(...).get()` — as SciJava Common's + `ModuleService` has long demonstrated. + - **Declining is not cancellation.** A preprocessor may stop a run before it + happens — the user closed the dialog, or a precondition is unmet — which + is an ordinary outcome, not an error and not interruption of work under + way. It is reported by `ExecutionResult.isDeclined()`, with `reason()` for + the user and `declinedBy()` naming the preprocessor. + - **`ExecutionResult` is deliberately not a `Future`.** A future's + `isCancelled()` and `get()` are coupled by contract — whenever the former + is true the latter must throw — so modelling a declined run as a cancelled + future turns it back into an exception, which returning a result was meant + to avoid. The asynchrony lives in `Future`; what happened + lives in the result. + - **`declinedBy()` returns the preprocessor instance, not its class**, since + a chain may hold two instances of one class and the useful question is + which of them objected. + - **Declining lives on the execution, not the preprocessor.** SciJava Common + put `isCanceled()`/`getCancelReason()` on the plugin, which made + preprocessors stateful and unsafe to share across runs. Telling the + execution keeps them stateless — which matters here, since `Context` + constructs a fresh plugin instance per call. + - Still to do here: factoring the framework-neutral member parsing out of + `ops-engine` — which is a larger job than it looks, see below. +- **Commands:** a struct, plus menu metadata, plus `run`. Commands and Ops + remain separate concepts sharing this layer. +- **Scripting:** `javax.script` is not general enough. GraalVM's Truffle + languages expose `org.graalvm.polyglot.Context` (a `ScriptEngine` shim loses + polyglot bindings, sandboxing and resource limits), and Appose-based Python + runs out of process, which `javax.script` cannot model at all. So: + - `scijava-scripting-api`: a small `ScriptLanguage`/`ScriptEngine` facade + with **no `requires java.scripting`**; + - separate adapter modules for `javax.script`, GraalVM polyglot, and Appose. + + `#@` parameters parse into structs. The external `scripting-*` repositories + migrate onto the facade. + +## Layering: how Fiji gets built on SJ3 + +The problem with SciJava Common was never that it had menus. It is that the +*core* had them: one artifact bundles the plugin framework, the application +container, menus, displays, tools and a UI opinion, so a library wanting only +plugin discovery inherits `MenuPath`, `Display` and a transitive AWT flavour. + +The remedy is **stratification, not deletion**. Everything Fiji needs still +exists; it moves to a layer that can be depended on separately. Where this +document says a subsystem is "dropped", that means *dropped from the core* — +unless it says there is no replacement at all, which it says explicitly. + +| Layer | Contents | Depends on | +| --- | --- | --- | +| **0. Foundation** (built) | `spi`, `common3`, `collections`, `priority`, `progress`, `concurrent`, `discovery`, `index`, `struct`, `io3`, `events`, `context`, `execute` | nothing above | +| **1. Application model** (built, less `scijava-desktop`) | `scijava-command3` (a struct plus presentation metadata, the menu tree and its walk), `scijava-ui3` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | +| **2. Toolkit bindings** | `scijava-awt` (and the AWT platform layer the others use), `scijava-swing`, `scijava-javafx`, headless | layer 1 | +| **3. Application** | Fiji: which menus exist, branding, update sites, defaults | layer 2 | + +Only layer 2 imports AWT or Swing. Layer 0 never mentions a user interface, so +headless is not a mode — it is what the core does. + +### The mechanisms are already in place + +The machinery the application shell needs was built in phases 1-3, and in +several cases was built *because* of it: + +| Fiji needs | Built | +| --- | --- | +| Menus over hundreds of commands, without loading their classes | the `@Plugin` index, and `Discovery.implClassName()`/`attrs()` before `type()`/`get()` | +| Parameter dialogs | `Preprocessor`: harvesting is a preprocessing step | +| "The user pressed Cancel" | `Execution.decline(reason)`, reported by `ExecutionResult` | +| Showing outputs | `Postprocessor`: the `DisplayPostprocessor` seam | +| Tool and input events, one handler claiming a keystroke | `scijava-events`, with `Consumable` | +| Menu ordering | `scijava-priority`, plus a weight attribute in the index | + +NB: SciJava Common builds its menu from `ModuleInfo`, whose +`getDelegateClassName()` names the class without loading it and whose +`loadDelegateClass()` loads on demand. That is exactly the +`implClassName()`/`type()`/`get()` split of `Discovery`, so the expensive part +of the menu story is already solved and tested. + +### What changes, and what does not + +**All of the presentation metadata survives** — menu path, label, icon, +accelerator, weight, selection group, visibility, enablement. Fiji's menus are +built from it, and hundreds of commands depend on it. + +**It becomes data rather than an inheritance chain.** SciJava Common has +`BasicDetails` → `UIDetails` → `AbstractUIDetails`, which every plugin extends +in order to carry a label. Here it is attributes in the annotation index, read +without loading, with a command-info view over `Discovery`. The same +information, fewer types, a faster menu build. Expect commands to look like: + +```java +@Command(menuPath = "Image>Adjust>Brightness/Contrast...", weight = 12, + accelerator = "^C", iconPath = "/icons/bc.png") +public class BrightnessContrast implements Runnable { + @Parameter private Dataset image; +} +``` + +`@Command` as its own `@Indexable` annotation: `IndexDiscoverer` is generic +over the annotation type, so a dedicated, self-documenting annotation costs +nothing compared with stuffing this into `@Plugin` attributes. + +**Reworked rather than ported:** `AbstractUIDetails`'s inheritance, the +`display` package's `DisplayService`/`Display`/`DisplayViewer`/`DisplayPanel` +layering, and the `Gateway`/`SciJava` convenience object. Those are shape +problems; the capabilities survive. + +### The real risk is migration, not architecture + +Hundreds of commands live across dozens of repositories with different +maintainers. Per command the change is mechanical, but there is no flag day on +which they all update. + +So the **one-way legacy bridge matters more than its place in the phases +implies**: for Fiji to run on SJ3 before every command is ported, an SJ3 +application must present SciJava Common `ModuleInfo`s as SJ3 commands — +metadata flowing both ways, dependency flowing one way. That is what makes the migration incremental +rather than a big bang. + +**`scijava-compat` is built**, and it is the only component that depends on +SciJava Common in this direction. `Legacy.commands(context)` turns everything +a legacy context can run into `CommandInfo`s, which go into an application's +menus beside its own commands and its scripts; `LegacyContext.start()` does the +same without the caller naming a SciJava Common type at all. + +What it found, which is why bridges are worth building early: + +- **`org.scijava.command` was a split package.** SciJava Common owns it, and + SciJava3 had claimed it too. Two modules containing one package is a JVM + that refuses to start, and on the classpath it is worse — two different + `Command` interfaces with one name, first jar wins. The incarnation rule was + applied to `io3`, `convert3` and `script3` and simply missed here. Hence + `scijava-command3` / `org.scijava.command3`, and the two can now sit on the + module path together. **No other package collides**: 77 SciJava Common + packages against 34 SciJava3 ones, and that was the only one. +- **SciJava Common's `@Parameter` means two things**, "this is an input" and + "inject this service", and its harvester tells them apart by having + preprocessors fill the services first. SciJava3 split the annotations so the + question never arises, so the bridge answers it once: a parameter whose type + is a `Service` or a `Context` is the legacy context's business and never + reaches a dialog. +- **`DynamicCommand` needs no equivalent to be supported.** Its parameters + live on the *module's* own description rather than the shared one, so the + bridge initializes at `create()` time (as SciJava Common's `InitPreprocessor` + does) and reads the module's description. What SciJava3 declines to *offer* + as a way of writing commands, it presents without trouble. +- **Callbacks and validators translate by name.** SciJava3 names a behavior + and asks the executable what the name means; for a legacy command it means a + method on the delegate object, which SciJava Common knows how to invoke. A + legacy dialog therefore behaves in a SciJava3 application exactly as it did + in an ImageJ2 one. SciJava Common's two validator protocols — throw, or + return a message — collapse into SciJava3's one, and the wrapper exception's + unhelpful "Error executing method" is unwrapped so the user sees what the + validator actually said. +- **Accelerators are translated, not passed through.** `Accelerator.toString()` + renders Swing's format (`"control L"`), which Swing would parse back and + JavaFX would not; written as `^L`, all three bindings agree. +- **Menu paths are rebuilt from their entries**, because + `MenuPath.getMenuString()` renders `"A > B > C"` with spaces that would end + up inside the menu labels. +- **The bridge's own API names no SciJava Common type**, which is not tidiness: + a JPMS module would otherwise have to `requires org.scijava` merely to hold + the context, and a test-scoped use cannot say that at all. + +**The compatibility components are named for the foreign thing each adapts +to**, which settled a name clash and shook out a mixed module: + +| component | what it does | depends on | +| --- | --- | --- | +| `scijava-compat` | SciJava Common's commands, usable from SciJava3 | scijava-common, command3 | +| `scijava-ops-compat` | SciJava3's ops, usable from a SciJava Common application | scijava-common, ops-api, progress | +| `scijava-imagej-compat` | teaches `scijava-types` to read a `Dataset`'s generic type | imagej-common, imglib2, types | + +The first two are the same boundary in opposite directions, and separate +because their dependencies are disjoint: an application wanting Fiji's +existing commands in its menus should not thereby acquire ops. The third was +hiding inside the old `scijava-legacy`, whose `module-info` said +`requires net.imagej` for *all* of it — so anyone wanting ops inside a +SciJava Common application was acquiring ImageJ2 to get it, on account of one +class that has nothing to do with SciJava Common at all. It is a candidate for +moving into an ImageJ repository entirely. + +Splitting it cost five build iterations, every one of them a dependency that +**no `import` names**: `scijava-discovery` arriving through `scijava-ops-api` +in one module and through `scijava-types`'s `requires transitive` in the +other; `net.imglib2`, because `Dataset`'s supertypes live there; +`scijava-ops-engine`, because `OpEnvironment.build()` finds its implementation +through `ServiceLoader`. **Module boundaries here cannot be drawn by reading +imports**, and single-module `-pl` builds cannot check them - they resolve +released artifacts, some of them a year old, rather than the reactor's own. +The old POM already carried a warning to this effect, which was pruned past +before it was read. + +### On the words + +The vocabulary is settled, and deliberately smaller than SciJava Common's: + +| word | means | +| --- | --- | +| **command** | anything a user can run, with declared parameters: `CommandInfo` | +| **class command** | one whose implementation is a class - Java, Kotlin, Groovy, any of them: `ClassCommandInfo`, read from the annotation index | +| **script command** | one whose implementation is source in some language, read by `scijava-script3` | +| **executable** | the layer below: something with parameters that runs, knowing nothing of menus or discovery (`org.scijava.execute.Executable`) | +| **module** | a JPMS module, and nothing else | +| **plugin** | anything discovered by `@Plugin` - a widget factory, a converter, a preprocessor. A command is one kind | + +What went away: *module* in SciJava Common's sense (it is now "command", +which is what users already called it); *macro*, which now means the ImageJ +1.x macro language and nothing here; and *extension*, which was never a +technical term. + +The `Command` interface is **not** the definition of a command - `CommandInfo` +is. It is how to write one as a class, and a script implements nothing at all +while being no less a command. Which is why the general type went from +`ExecutableInfo` to `CommandInfo`, and the class-based one from `CommandInfo` +to `ClassCommandInfo`: naming the general thing after what users actually call +it costs one word, and the alternative was SciJava Common's design with +"executable" substituted for "module". + +### Conversion + +**`scijava-convert3`** (`org.scijava.convert3`) is built, and deliberately +sits *below* the execution layer rather than beside it: binding an input to a +parameter converts, and a script, a command line and a text field all have +only strings to offer. It therefore depends on nothing but `scijava-common3` +and `scijava-priority`, and finds its converters through plain +`ServiceLoader` - no container, no configuration. + +- **One question, not eight.** `Converter.supports(Object, Type)` asks whether + *this value* can become *that type*. SciJava Common had eight `canConvert` + overloads across `Object`/`Class`/`Type` pairings, plus a + `ConversionRequest` to carry the combinations, plus an `AbstractConverter` + to make the overloads bearable. The declared `sourceType()`/`destType()` + answer the common case by themselves, so + `Converter.of(String.class, Foo.class, Foo::parse)` is a whole converter. +- **One converter per concern**, rather than one `DefaultConverter` holding + every special case in sequence: cast, number, string-to-value, to-string, + array, collection, file/path, and a wrapping constructor last. Each is + discoverable, orderable by priority, and replaceable by a downstream + component that disagrees. +- **Unconvertible is not zero.** `tryConvert` returns empty where a value + cannot become the type, and `convert` throws. Text that is half-typed + converts to nothing, which is what lets a widget leave a parameter alone + while the user is still typing - a distinction SciJava Common could not + make, since it returned null for both "no" and "null". +- **Exactness where the type asked for it**: a `BigDecimal` from a `double` + goes through the string, so `0.1` is `0.1` and not the binary double nearest + to it. +- **It removed duplication rather than adding a layer.** The UI's + `Widgets.toType` - three bindings' worth of text parsing - is now one call + into it, so a widget turning text into a value does exactly what a script + does. + +Still open: an `ObjectService` equivalent (what values *exist* that could be +converted into this parameter) is what a chooser widget wants, and is a +separate question from what conversions are possible. It lands with the object +widget. + +### Scripting + +**`scijava-script3`** (`org.scijava.script3`) is built, and the shape of it is +one claim: **a script is an `Executable` like any other**. It runs through the +same `Runner`, meets the same preprocessors, harvests its inputs through the +same dialog in whichever toolkit, and can sit in a menu beside a Java command. +Nothing above this layer knows which it is - a claim now tested, in +`scijava-swing`, by rendering a Groovy script's parameters with the Swing +widgets and watching its callbacks fire. + +- **`javax.script` is the first backend, not the only one.** `ScriptLanguage` + and `ScriptSession` are the SPI; `JsrLanguage` turns every JSR-223 engine on + the classpath into one, so Groovy, Jython, JRuby, Clojure and BeanShell cost + nothing and need declare nothing. GraalVM polyglot and Appose are further + `ScriptLanguage` implementations when they are wanted, and neither needs this + layer to change. +- **Callbacks work for scripts**, which SciJava Common never managed. + `ExecutableInstance.behavior(String)` resolves a name however that kind of + code does, and for a script that means a function the script defined - so + `#@ double(callback = "celsiusChanged") celsius` behaves exactly as the Java + annotation does. What the function changed is converted back into the + parameters afterwards, because a callback exists precisely to change other + parameters. Validators and `choicesFrom` come along for the same ride. +- **A session, not an `eval`.** Keeping the interpreter is what makes the + functions callable afterwards, and it is why `ScriptSession` exists rather + than a one-shot evaluation. +- **Declarations are blanked, not removed**, so the language's own error + messages still point at the line the author is looking at. `#@` is a comment + in Python and a syntax error in Groovy, so leaving it in was not an option. +- **Every declared parameter is bound, including the unfilled ones.** A + language that resolves names at run time fails on an unbound name rather + than seeing null, so a callback mentioning a parameter the user has not + reached would otherwise blow up instead of behaving. +- **Not ported**: the `ScriptProcessor` chain, which existed to rewrite a + script before evaluation and was used for parameters and a shebang, both of + which the header reader does directly. `ScriptService` has no equivalent - + `Scripts.get().of(path)` is a line. `ScriptREPL`, `ScriptInterpreter`, + `AutoCompleter` and `CodeGenerator` belong with the script editor. +- **A script declares its presentation the way a command does.** + `#@script(menu = "Process>Filters>Blur It", accelerator = "^B", label = ..., + weight = ..., iconPath = ...)` carries the same keys `@Menu` does, and where + a script says nothing, the directory it sits in speaks for it: + `scripts/Process/Filters/Blur_It.groovy` means the same thing, underscores + read as spaces. That is how the scripts already in the wild are arranged, so + it had better keep working. +- **A script names its own language** with `#@script(language = "jython")` or + a `#!` line, which is what settles the languages sharing an extension - + `.py` being Jython or Python depending on which is meant. Saying nothing + means the extension decides, as before. +- **`CommandInfo` is how an application takes more than one source.** A + `ClassCommandInfo` is one; `CommandInfo.of(executable, attrs)` makes one out + of a script and its directives; a SciJava Common `ModuleInfo` will make one + through the bridge. An application gathers what it can run from as many + sources as it has, and nothing downstream learns there was more than one. + **This is the `AppLoader` shape the `fijifx` notes sketched**, and it is now + load-bearing rather than planned: all three shells build their menus from + the annotation index *and* a directory of Groovy scripts, and nothing below + them can tell which came from where. + - It is **not** called `MenuEntry`, and the distinction matters: a menu path + is optional. A command invoked by name from a script, one reached only + through the search bar, one that exists to be called by something else - + all are ordinary things to have, and all are perfectly runnable. + `MenuTree` takes the subset with a path; the type is named for what it is + rather than for the one place it is most often shown. +- **Open**: whether a parameter with declared choices and no value should + preselect the first. SciJava Common does; the model here leaves it unset, + which is honest but shows an empty chooser. It is a one-line change in + either direction and wants a look at real dialogs before being made. + +### The command line + +**`scijava-cli`** (`org.scijava.cli`) is built: one component where SciJava +Common had three packages (`console`, `main`, `run`). + +``` +scijava "Gaussian Blur" --sigma 2.5 --edges Wrap +scijava blur.groovy --sigma=2.5 +scijava --list filters +scijava --help "Gaussian Blur" +``` + +- **A command is named the way a person would name it** - by menu path, by + label, by the tail of its class name - and matching is forgiving about case, + spacing and a trailing ellipsis. An exact label beats a partial one, so + "Brightness" still reaches `Brightness...` although + `Brightness/Contrast...` exists; where two answer equally, it lists them + rather than guessing. +- **`--option value` pairs are the inputs**, and they arrive as strings that + `scijava-convert3` turns into whatever the parameters declared. That is the + same path a script header and a text field take, which is why the command + line needed no conversion code of its own. +- **There is no `canRunHeadless()`.** SciJava Common needed it because + harvesting was tangled with the UI; here the harvester is one preprocessor + among several and this runner simply does not install one. A command that + cannot get an input it needs is declined, with a reason, through the + mechanism a dismissed dialog already uses. +- **`--help` reads the same metadata the menus and dialogs read**: labels, + types, choices, which inputs are optional. Nothing describes a command + twice. +- **Not ported**: `ConsoleService` and `ConsoleArgument`, the pluggable + argument mechanism. It earns its place when a component actually needs to + add an argument, and until then a parser of some forty lines does. `MainService` + has no equivalent either - `main` is a method. + +### Settings + +**`scijava-settings`** (`org.scijava.settings`) is built: one TOML file a +person can read, edit, copy between machines and name in a forum post. + +- **One path on every platform**: `~/.config//settings.toml`, honoring + `XDG_CONFIG_HOME`, overridable by a system property. Not what each platform + would prefer, and deliberately: what was wrong with `java.util.prefs` was + that the store was *opaque*, and the cure for that is one answer to "where + is it?" rather than three correct ones. Appose made the same choice. +- **`Settings` is an object, not a singleton and not a service.** Settings + belong to an application, so two applications sharing a JVM get two. This is + the line the codebase had been drawing without saying: *static facades for + stateless capabilities* (`Converters`, `Locations`, `Scripts` - converting a + string to an `int` does not depend on which application you are), *scoped + objects for anything with identity or lifetime*. Making settings static + would have quietly spent the multiple-contexts promise, and the answer to + "why have a container at all, then" would have been "no reason". +- **A table per command, a key per parameter**, keyed by the command's + identifier - the class name, or the script's path - and not by its + component's coordinates: a class moving between artifacts would otherwise + lose what the user chose, and that is a refactor nobody should be punished + for. +- **Values map onto TOML's own types**, by a small hardcoded table, with a + string as the fallback for anything else. So a `List` is an array of + strings rather than one mangled string, and reading it back converts + element-wise through `scijava-convert3`. Widgets need no serialization API + for this, which is just as well: serialization is a property of the *type*, + and a widget is a view. That is also why it needs no answer per toolkit. +- **Comments survive a save.** The file is one people edit; a store that ate + the `# 0.7 because the detector is noisy` somebody left beside a value is a + store they would stop editing. +- **The TOML is parsed here**, covering what settings need - tables, all four + string forms, integers in four bases, floats, booleans, all four + date-and-time types, arrays - and refusing inline tables and arrays of + tables by line number rather than misunderstanding them. Neither candidate + library is modular, and an automatic module in a foundation component is the + same trade that dropped MigLayout; the API is small enough that `tomlj` can + take its place if settings outgrow this. +- **Persistence is two preprocessors**, `LoadInputs` and `SaveInputs`, so that + a caller can run a command without persistence interfering - which is what + the processor chain is for. Saving is a *pre*processor, after the harvester: + doing it after the run would lose the values whenever the run threw, which + is exactly when the user is about to try again and exactly when retyping + everything is most infuriating. + +It also turned up two things below it: + +- **`Execution.isSupplied(key)`** is new, because "did the caller pass this?" + is not the same question as "is this parameter empty?" - a `double` is never + empty, so loading remembered values by looking for nulls would never fill a + primitive, and overwriting regardless would make a remembered value beat an + explicit one. SciJava Common answered this with a mutable `resolved` flag on + each parameter; the run knows what it was given, so it is asked instead. +- **A bug in `scijava-convert3`**: `CastConverter` saw a `List` going to a + `List`, said "already the right type", and handed back a + `List` unchanged - the right container full of the wrong things, + which surfaces much later as a `ClassCastException` somewhere else. It now + declines a parameterized collection and lets the element-wise converter have + it. + +### Phase 4 — UI and desktop + +- **`scijava-harvest`** (`org.scijava.harvest`): the model behind a parameter + dialog — groups, dependencies between parameters, validation — with no + toolkit anywhere, so it is testable headless and a Swing or JavaFX binding + renders it. **Prototyped**, against the four dialog behaviours actually + asked for, and each taught something: + - **A chosen implementation brings its own parameters.** Expansion is driven + by the **value's class**, not the declared type: the field says `Joke`, the + value is a knock-knock joke, and it is the joke's parameters that appear. + Expanding the declared type finds nothing, an interface having no fields — + so `Structs.expand`, which uses the static `childStruct()`, is not enough. + - **Static nesting is then the degenerate case** where the value's class + never varies. One concept covers both, and the reason is the value-driven + expansion rather than anything about `isStruct()`. + - **A group whose size depends on a value cannot be fields at all**, since a + class's fields are fixed when it is compiled. It needs parameters built at + runtime — which is exactly what a script header needs, so both are served + by `Parameters.builder()`, members over a map. Two requirements that looked + unrelated turned out to be one primitive. + - **An "advanced" toggle** is a group with a `visibleWhen` behavior; a + hidden group disappears rather than showing as an empty box. + - **Callbacks work for scripts**, which SciJava Common never managed: it + resolved `callback = "foo"` by Java reflection, leaving a script nothing to + name. `ExecutableInstance.behavior(String)` resolves a name however that + kind of code does — Java reflects a method, a script asks its engine for a + function. + - **Cascading callbacks terminate.** A callback may change any parameter, so + changes cascade, and two that set each other — Celsius and Fahrenheit, a + real case — would never stop. Each parameter's callback runs at most once + per `set`. + - **Validation has one protocol**: return a message, or do not. SciJava + Common allows either throwing *or* returning a non-empty string, which is + why it needs both `validate` and `validateMessage`; with one protocol a + thrown exception means the validator is broken, rather than being a second + way to report a bad value. + - **`ObjectService` is not the mechanism** after all. A subgroup's parameters + come from whatever object the parameter holds; where that object came from + is a separate question, for a chooser widget's candidate list. It is one + source among several, so the harvester is not blocked on it. + - **`DynamicCommand` is supported in use, not in form.** Counting its uses + across the scijava and imagej repositories shows two different needs + wearing one coat: about 39 files mutate the metadata of a *declared* + parameter (`setChoices`, `setMinimumValue`), and about 31 add parameters + that do not exist statically (`addInput`). SciJava Common serves both by + making `ModuleInfo` mutable and per-instance, which is precisely what makes + harvesting hard: a platform cannot know a command's parameters without + constructing it. + - Adding parameters is the generated group above — and better, because it + is *declared*: `@Group(membersFrom = ...)` tells a platform exactly where + the dynamic part is and that everything else is fixed. + - Computing metadata is `@Parameter(choicesFrom = "behavior")`, which + leaves the parameter statically visible while its values follow the + state. `@Parameter(choices = {...})` covers the static case, and + `ParameterNode.choices()` hands a UI the resolved list either way. Being + a behavior, it works for scripts too. + - So there is no `DynamicCommand` base class and no mutable description. + If `min` and `max` want the same treatment, `minFrom`/`maxFrom` follow + the identical pattern — worth adding when a widget needs it, not before. + - **Known rough edges**: group placement is provisional (a group appears + where its first member would, and a generated group needs an explicit + `after` anchor); `ParameterModel` decides the tree changed by comparing + `toString()`, which is right for correctness and wrong for a UI that should + patch rather than rebuild — the Swing dialog rebuilds its widgets whenever + the tree changes, which is correct and loses focus on a parameter whose + callback reshapes the dialog. + +- **`scijava-ui3`** (`org.scijava.ui3`) and **`scijava-swing`** + (`org.scijava.swing`): the contracts, and the first toolkit to meet them. + **Built**, and the shape of the split is the point: + - `Widget` is a view of a `ParameterNode`, and that is all it is. There is no + `WidgetModel`: SciJava Common needed one because a widget could not see the + module, whereas a widget here reads the node for label, description, + bounds, style and resolved choices, and writes through `ParameterModel`. + Writing through the model is what makes callbacks run and the dialog + reshape itself, so a widget gets the dynamic behavior by not doing anything + clever. + - `WidgetPanel` **is a `Widget`**, so a group nests in its parent's list with + no special case, however deep. `WidgetPanels` walks the tree the same way + for every toolkit; a binding supplies factories and a panel factory, some + 150 lines of tree-walking it no longer writes. + - `InputHarvester` is a `Preprocessor`. Dismissing the dialog is + `Execution.decline`, an ordinary outcome that the `ExecutionResult` + reports, rather than a `canceled` flag on a stateful preprocessor. + - **MigLayout is gone**, replaced by `GridBagLayout`: two-column label-field + layout is not worth a third-party dependency, still less an automatic + module in a JPMS build. + - **Seven widgets so far** — number (spinner, with an optional slider or + scroll bar, and labelled tick marks), text (field, password, area, and the + arbitrary-precision numbers a spinner cannot step), toggle, choice, file, + message, and the panel itself. The button, color, date, file-list and + radio-choice widgets are not ported; each earns its place when something + asks for it. + - **An enum needs no annotation**: its constants are its choices, resolved + in `ParameterTree` so every toolkit gets it. `softMin`/`softMax` bound the + slider where the permitted range is far wider than the useful one, while + the spinner still enforces the hard `min`/`max`. + - **A widget reads its node out of the current tree**, not the one it was + built from. The tree is rebuilt on every `set`, so a captured node goes + stale — and since `ParameterModel` rebuilds the widgets only when the + tree's *shape* changes, a parameter whose computed choices changed while + its shape did not would otherwise keep offering the old values. + - **The dialog never moves and never shrinks.** After the first build it may + grow, to make room for parameters that have just appeared, but a dialog + that re-centred itself, or closed up around a group the user had just + collapsed, would move the controls out from under the pointer. + - **An empty dialog says why.** A missing annotation index — javac produces + one only when scijava-index is on the annotation processor path, and says + nothing when it is not — means no widget factories are discovered, and the + dialog comes up with nothing but OK and Cancel. That is now a warning + naming the missing index, as is a parameter no factory accepted. + - **Style hints are advisory strings** (`@Parameter(style = "slider")`, + `"format:0.00"`), which a toolkit is free to ignore, so a parameter styled + for Swing still renders elsewhere. `min`, `max` and `stepSize` are strings + too, read back in the parameter's own type — because metadata has to + survive a script header. + - **Demos with `main()`** (`Demos` in the test sources) put each tricky + behavior on screen: the kitchen sink, the advanced toggle, the generated + group, computed choices, live validation, and two values that keep each + other in step. + +- **Input harvesting is a headline deliverable**, not a port. It is the + workhorse that lets Fiji users write scripts without UI-specific concerns. + Build it on `scijava-struct`, and complete the long-requested + **dynamic subgroups** (widgets appearing based on another widget's value) — + see [scijava-common#42](https://github.com/scijava/scijava-common/issues/42#issuecomment-332658377) + and the prototype preserved on the `historical/scijava-ops-prototype` branch + (`widget/`, `swing-widget/`), which is the design starting point rather than + `org.scijava.widget` as written. +- **A shell exists, as a demo**: `Shell` in `scijava-swing`'s test sources is + a window with menus, a search bar and a status line. It is the first thing + to put the whole stack together - commands found through the annotation + index, arranged by `MenuTree`, rendered by `SwingMenus`, run by `Runner`, + filled in by `SwingInputHarvester` - and starting it loads no command class. + `MenuCreator`/`Menus` (in `scijava-command3`, beside the tree they walk) hold + the toolkit-free part of the walk, so a second toolkit implements four + methods and inherits the rest. + +- **From the `fijifx` experiment** (unpublished, since retired), which asked + the right questions before this design existed: + - *"Is there any way we can build the menus before loading the command + classes? Not if we use `@Plugin` annotations on the commands themselves + -- we can do it if all menu items are scripts."* That conclusion is now + wrong, and usefully so: the annotation index is read as **data**, and + `@Menu` is indexed separately from `@Plugin` and joined on the class name, + so a menu of ten thousand commands costs no class loading at all. The + experiment's own class-loading test - a static initializer that sleeps a + second - is the measurement that made the question urgent. + - **`AppLoader` is the legacy bridge's shape.** The experiment sketched one + loader per source of commands - SciJava's plugin index, ImageJ 1.x's + `plugins.config`, a Fiji `.toml`, a directory of scripts - each + contributing menu entries into one application model, and each able to + report progress for a splash screen. That is what the bridge needs: + several discovery sources feeding one `MenuTree`, with SciJava Common's + `ModuleInfo`s as one source among them rather than a special case. + - **The main window is menu bar, tool bar, progress bar, status bar and a + search field**, and the search field is not an afterthought: a command + that is in no menu is still reachable, which the demo shell shows. + +- **A second binding, `scijava-javafx`**, is what turns "toolkit-agnostic" + from an assertion into a measurement. The whole of `scijava-ui3` survived it + with **two changes**, both of which were Swing keeping something that was + never Swing's: + - `Widget.isLabeled()` - whether a parameter wants a label beside it is a + statement about the parameter, and every toolkit would have answered it + identically. + - `WidgetPanel.showProblems(Map)` - how a problem *looks* is a toolkit's + business, but that there is one, shown per parameter and cleared when + fixed, is not. + Everything else - `Widget`, `WidgetFactory`, `WidgetPanelFactory`, + `WidgetPanels`, `Widgets`, `InputHarvester`, `MenuCreator`, `Menus` - was + untouched, and `ParameterModel` and `ParameterTree` never came into + question. + +- **What the second binding had to do differently** is worth recording, + because it is what a third will face too: + - **The toolkit must be started, once**, and JavaFX has no `invokeAndWait`. + `FxThread` is that plumbing, and it deliberately does not use + `Application.launch`, which takes over the process and returns only when + the last window closes - wrong for a binding that an application, a script + or a test may use. + - **`showAndWait` runs a nested event loop**, so the dialog must be built + and shown in one visit to the JavaFX thread, where Swing wanted two. + - **JavaFX spinners do not preserve a parameter's type** - their value + factories are `Integer` or `Double` and nothing else - so the widget + converts on the way out, and an editable spinner needs its editor watched + or a typed value is silently lost when focus moves. + - **Some things are simply easier**: a collapsible group is a `TitledPane`, + where Swing needs a hand-rolled header button; `KeyCombination.SHORTCUT_DOWN` + says what Swing needs a `Toolkit` call to compute. + +- **A third binding, `scijava-awt`**, is where "toolkit-agnostic" stops being + a claim about two similar toolkits. It changed **nothing** in the contracts. + What it did change is where the shared code lives: + - `AbstractWidget` moved into `scijava-ui3`, because three bindings had + written the same bookkeeping - find the node in the *current* tree, write + through the model, do not mistake a refresh for the user typing. Two would + have been a coincidence. + - `Accelerator` moved into `scijava-command3`, because Swing wants a + `KeyStroke`, JavaFX a `KeyCombination` and AWT a `MenuShortcut`: three ways + of *saying* a shortcut, one thing being said. A binding that parses `^+C` + itself is a binding that can disagree about what it means. + - AWT is the harshest case so far, and instructive for it: no spinner, no + slider, no titled border, no collapsible pane, no icons in menus, and a + `MenuShortcut` that cannot express alt. The number widget is a text field + and a scroll bar honoring `min`, `max`, `stepSize` and the soft bounds; + the collapsible group is a button that hides a panel; an accelerator AWT + cannot say is dropped rather than approximated into some other key. + **None of it asked the model or the contracts for anything.** + - It also found a difference worth knowing: AWT's `TextComponent.setText` + does not notify text listeners, where a Swing document would. Harmless + here - the refresh guard covers it either way - but it is the kind of + thing only a third toolkit tells you. + +- **On the structure of `scijava-awt` and `scijava-swing`**, which SciJava + Common got into four components (`scijava-ui-awt`, `scijava-ui-swing`, + `imagej-ui-awt`, `imagej-ui-swing`): the evidence says the toolkit axis is + not where the duplication was. `scijava-ui-swing` imported exactly six + things from `scijava-ui-awt` - `AWTInputEventDispatcher`, + `AWTDropTargetEventDispatcher`, `AWTColors`, `AWTWindowEventDispatcher`, + `AWTWindows`, `AWTClipboard` - and **not one widget**, because + `java.awt.Choice` and `JComboBox` have nothing whatever in common. So: + - **Swing depends on AWT for the platform, never for widgets.** `Edt` is the + first instance: the dispatch thread belongs to `java.awt.EventQueue`, and + `SwingUtilities` merely forwards to it, so code that needs to reach the + EDT need not drag in Swing. Colors, cursors, clipboard, drag-and-drop, + window placement and screen geometry follow the same way. + - **The widget types stay siblings.** `SwingWidget` could extend + `AwtWidget` - `JComponent` is a `Component` - but discovery filters on + `WidgetFactory.widgetType()`, so that would mean a pure-AWT application + silently filling its dialogs with Swing controls the moment scijava-swing + appeared on the classpath. Mixing the two is then an explicit adapter, + which is what embedding an ImageJ 1.x canvas in a Swing frame will want + anyway. + - **The image-processing layer should not repeat the matrix.** What made + four components was (generic | ImageJ) x (AWT | Swing), and the ImageJ + half of that is mostly one thing: a canvas that paints a `BufferedImage` + and handles a tool's events. That is AWT code, and it works unchanged + inside a Swing frame - which is precisely what ImageJ 1.x has always done. + So the image layer wants an AWT canvas plus per-toolkit frames, not two + parallel implementations. + +- **`scijava-ui3-test` is the anti-drift mechanism**: the sample commands and + a conformance suite that every binding extends - eight assertions about + widgets, three about menus. Swing and JavaFX render the same declarations + and are held to the same behavior, and a third binding starts by making the + suite pass. A binding's own tests then cover only what is genuinely its own: + which control it chose, how it parses an accelerator. + +- **The application shell moves to layers 1 and 2**, it is not discarded: + `ApplicationFrame`, `Desktop`, `StatusBar` and `ToolBar` are contracts in + `scijava-ui3` and implementations in the toolkit bindings. What the core + drops is any knowledge that they exist. +- **`input` and `tool`** are re-framed, not frozen: UI-agnostic facades over + GUI concepts, layered on `scijava-events`, letting code listen for GUI + events or implement interactive behavior (e.g. a pencil tool) without + binding to Swing or JavaFX. +- **`display`** gets an overhaul, not a port. The role — matching a module's + outputs to a way of presenting them, as `DisplayPostprocessor` does — is + worth keeping; the current design is overengineered. +- **`platform` and `ui.dnd`** feed a **`scijava-desktop` modernization + track**: desktop integration, drag-and-drop and `fiji://` links matter to + the community and must keep working. +- **CLI module:** `console`, `main`, `run`. **`startup` stands alone** — it is + relevant whether the application starts from the CLI or a GUI. +- **Likely dropped:** `welcome`, `download`, `text`, `options`. + +## Per-subsystem disposition + +| SJC package | Disposition | +| --- | --- | +| `util` | Split across `common3`, `collections`, `meta`, `spi`; remainder dropped | +| `annotations` | → `scijava-index` (Phase 1) | +| `io.location`, `io.handle`, `io.nio` | → `scijava-io3` (Phase 1) | +| `event` | → `scijava-events`, clean-room rewrite (Phase 1) | +| `parse` | → Parsington (Phase 1) | +| `log` | Dropped — use SLF4J | +| `cache` | External `scijava-cache` moves in (Phase 2) | +| `thread` | → `scijava-concurrent` | +| `task` | → `scijava-progress` | +| `Context`, `plugin`, `service`, `object`, `prefs`, `app` | → `scijava-context` (Phase 2) | +| `convert` | → `scijava-convert3` (Phase 2) | +| `module`, `command` | → execution layer (Phase 3) | +| `script` | → scripting facade + adapters (Phase 3) | +| `widget` | → input harvesting, redesigned (Phase 4) | +| `ui`, `menu` | `scijava-ui3` contracts and `scijava-menu` (layer 1), with toolkit bindings in layer 2 (Phase 4) | +| `input`, `tool` | Re-framed as UI-agnostic facades (Phase 4) | +| `display` | Overhauled, not ported (Phase 4) | +| `platform`, `ui.dnd` | `scijava-desktop` modernization (Phase 4) | +| `console`, `main`, `run` | Single CLI module (Phase 4) | +| `startup` | Standalone module (Phase 4) | +| `welcome`, `download`, `text`, `options` | Likely dropped | +| `minimaven`, `test` | Out of scope | + +## Per-subsystem recipe + +1. Port the code and tests (JUnit 4 → JUnit 5) with a `module-info.java`, no + SJC dependency, and no split packages. +2. Add an adapter in `scijava-compat` (or `scijava-ops-compat`, for the + opposite direction) only if a concrete need exists. +3. Mark the SJC counterpart `@deprecated` with a pointer, and add rows to + [migration.md](migration.md). +4. Update the downstream components that were using it. + +## Scope: satellite repositories + +The end state is that this repository contains all *core* SciJava3 +subsystems, with no dependency on external `scijava-` components. +`scijava-cache`, `scijava-listeners`, `scijava-optional`, `scijava-table`, +`scijava-search` and `scijava-log-slf4j` all move in over time. Components +intended to work standalone — Parsington, for instance — keep their +independence and should not carry the `scijava-` prefix. + +### The core services are not a block to port + +The plan once said Phase 2 would bring over "the core services: object +registry, prefs, thread, app/status". Measured against usage and against what +has since been built, most of that does not survive: + +| SJC service | Files outside SJC | Disposition | +| --- | --- | --- | +| `StatusService` | 57 | **Superseded** by `scijava-progress`. Porting it would re-create what was just replaced. | +| `ThreadService` | 76 | Mostly EDT dispatch, which is a **UI** concern: `scijava-concurrent` covers parallelism, and the rest belongs with the UI layer (Phase 4). | +| `ObjectService` | 52 | Real, but its consumers are the widgets and the conversion layer. It should land with them, so its shape is driven by a consumer rather than guessed (Phase 3/4). | +| `AppService` | 52 | Largely version and title metadata, which `scijava-meta` covers; the rest is app-shell material already dropped. | +| `PrefService` | 31 | Split in two — see below. | + +#### Preferences: a store, and a policy + +`PrefService` is really two things, and only one of them is core. + +**Widget value persistence** — remembering what a user last typed into a given +parameter of a given module — is *policy*. It needs module identity, parameter +names, and a rule for when to save, none of which mean anything without the +input harvester. It belongs with the harvester, in Phase 4. + +**A settings store** — durable key/value state in a file — is a general +capability whose API needs no consumer to get right. Deferred, but with the +design settled now, so that Phase 4 inherits decisions rather than a debate: + +- **Not `java.util.prefs`.** Its opacity is the actual complaint: the registry + on Windows, plists on macOS, `~/.java/.userPrefs` on Linux; values over 8KB + silently truncated; nothing a user can inspect or diff. +- **TOML.** It reads like the INI files everyone already understands + (`key = value`), supports comments, and — unlike YAML — is not + whitespace-fragile and has no implicit typing, so `no` stays the string + `no` and `1.10` stays `1.10`. Those YAML traps bite exactly the values a + config file holds. Jaunch already uses TOML, so the format is familiar + across the stack. The cost is a dependency: the JDK has no TOML parser, + where `snakeyaml` was already present — worth choosing the library when + this is built. +- **`$XDG_CONFIG_HOME/fiji`, else `~/.config/fiji`** — what users asked for — + on every platform rather than `%APPDATA%` and `~/Library`. The complaint was + opacity, and a path users can find, back up and quote in a bug report beats + platform convention here. Overridable by system property, and the effective + path should be printable at runtime. +- **Namespaced by class**, as SJC does, mapping onto sections in the file. +- **No migration** from the old store: SJC keeps working for SJC, and a + one-time import is a feature to add if asked for, not a reason to couple the + two. +- **Open, and genuinely blocked on the harvester:** what to do about stale + keys. Remembered values accumulate for modules that no longer exist, and + neither SJC nor a plain file store has an answer. The harvester is what + knows which keys are still live. + +## Later, not now + +- **`scijava-persist`**: polymorphic serialization where the implementations + live in *other people's* repositories, resolved by discovery — a real and + recurring need, and the discovery half is exactly SciJava-shaped. Worth + rebuilding when a consumer appears, but not worth lifting: it hard-wires Gson + into its SPI (`IClassAdapter extends JsonSerializer`), vendors a copy of + Gson's `RuntimeTypeAdapterFactory`, and is built on `Context` + `@Plugin`. A + rewrite would be much smaller, since Jackson has first-class polymorphic + support and only the discovery-driven subtype registration would be ours. + Its author ended up inlining equivalent logic downstream, and there is no + concrete core consumer today. It was never released, and its code stays in + the archived incubator, at + [`99712e0`](https://github.com/scijava/incubator/tree/99712e0471d36339e84852871614a96380916364/scijava-persist), + as a reference for that rewrite. + +## Open decisions + +- **Names:** the execution-layer module, and `scijava-convert3`. +- **`util.Timing` and `util.TreeNode`:** port or drop, pending evidence of + downstream usage. +- **A build-time check that every consumed SciJava artifact is version-pinned + in the consuming POM** — see hard constraint 4. Today this is caught only by + a full clean build after an API change. +- **`display`:** what the redesigned output-presentation mechanism looks like. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 000000000..a8ac5d3e1 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,140 @@ +# SciJava Common → SciJava3 migration table + +Where each [SciJava Common](https://github.com/scijava/scijava-common) (SJC) +class has gone. See [migration-plan.md](migration-plan.md) for the strategy. + +**Status values:** + +- **done** — the SJ3 replacement exists and the SJC class carries an + `@deprecated` pointer. +- **ported** — the SJ3 replacement exists; the SJC deprecation pointer is + still to be added. +- **planned** — decided, not yet built. The phase is noted. +- **dropped** — no replacement. Use the standard library, or inline it. +- **open** — undecided. + +A **dropped** row is a successful outcome, not a gap. + +## `org.scijava.util` + +| SJC class | Replacement | Status | +| --- | --- | --- | +| `AbstractPrimitiveArray` | `org.scijava.collections.AbstractPrimitiveArray` | ported | +| `AppUtils` | `org.scijava.common3.Apps` | ported | +| `ArrayUtils` | mostly dropped: `contains`/`indexOf` → `Arrays.asList(a).contains(v)` for object arrays, a loop or stream for primitives; `array` → an array literal; `toCollection` → no replacement. `safeMultiply32`/`safeMultiply64` → `common3.Numbers` (they wrap `Math.multiplyExact`) | ported | +| `BoolArray`, `ByteArray`, `CharArray`, `DoubleArray`, `FloatArray`, `IntArray`, `LongArray`, `ObjectArray`, `ShortArray`, `PrimitiveArray` | `org.scijava.collections.*` | ported | +| `Bytes` | `org.scijava.io3.Bytes` | ported | +| `CheckSezpoz` | none | dropped | +| `ClassUtils` | `org.scijava.common3.Classes` (mostly already deprecated in SJC) | ported | +| `ColorRGB`, `ColorRGBA`, `Colors` | UI layer (Phase 4) | open | +| `CombineAnnotations`, `Combiner`, `MetaInfCombiner`, `ServiceCombiner` | build-time tooling; `scijava-maven-plugin` | dropped | +| `ConversionUtils` | `org.scijava.convert3.Converters`; already deprecated in SJC | ported | +| `DebugUtils` | `org.scijava.common3.Threads` | ported | +| `DefaultTreeNode`, `TreeNode` | on hold — only `imagej-common` uses it; may move there | open | +| `DigestUtils` | `org.scijava.common3.Digests`. `digest` now throws `IllegalArgumentException` for an unknown algorithm rather than returning `null`; `best*` is SHA-1, since every Java platform guarantees it | ported | +| `FileUtils` | split: filename logic → `org.scijava.common3.FilePaths` (`getPath` → `normalizeSeparators`, `getExtension` → `extension`, `stripFilenameVersion` → `stripVersion`, `getAllVersions` → `allVersions`, `matchVersionedFilename`, `deleteRecursively`); `urlToFile`/`listContents`/`appendContents` → `common3.URLs`; `findResources` → `scijava-index` (Phase 1) | ported | +| `FileUtils` (dropped parts) | `readFile`/`writeFile` → `java.nio.file.Files.readAllBytes`/`write`; `createTemporaryDirectory` → `Files.createTempDirectory`; `getModifiedTime` → `Files.getLastModifiedTime`; `shortenPath`/`limitPath` → no replacement, used only inside SJC itself | dropped | +| `GenericUtils` | `org.scijava.common3.Types` (already deprecated in SJC) | ported | +| `IntCoords`, `IntRect`, `RealCoords`, `RealRect` | UI layer (Phase 4) | open | +| `IteratorPlus` | none — use the standard `Iterator`/`Iterable` API | dropped | +| `LastRecentlyUsed` | `java.util.LinkedHashMap` with `accessOrder=true` + `removeEldestEntry` | dropped | +| `ListUtils` | dropped: `first` is one line, inline it; `string` → `StringJoiner` or `String.join` | dropped | +| `LineOutputStream`, `ReadInto`, `StringMaker`, `Query` | none — inline as needed | dropped | +| `Manifest` | `org.scijava.meta.Manifest` | ported | +| `MersenneTwisterFast` | `org.scijava.common3.MersenneTwisterFast` | ported | +| `MirrorWebsite`, `TunePlayer`, `ReflectedUniverse`, `ReflectException` | none | dropped | +| `MiscUtils` | `org.scijava.common3.Comparisons` | ported | +| `NumberUtils` | `org.scijava.common3.Numbers`. `getMinimumNumber`/`getMaximumNumber` → `minimum`/`maximum`; `asBigDecimal`/`asBigInteger` → `bigDecimal`/`bigInteger`; `clampToRange` → `clamp`. **`toNumber` no longer consults a conversion framework** — it handles numbers and strings only, so `common3` stays dependency-free | ported | +| `PlatformUtils` | `org.scijava.common3.Platforms` | ported | +| `POM` | `org.scijava.meta.POM` | ported | +| `Prefs` | `org.scijava.settings.Settings`; already deprecated in SJC | ported | +| `ProcessUtils` | `org.scijava.common3.Processes`, reimplemented on `ProcessBuilder`; no longer needs `ReadInto` | ported | +| `PropertiesHelper` | none — inline | dropped | +| `Sizable`, `SizableArrayList` | none — the reflective-resizing rationale died with Java 17 | dropped | +| `StringUtils` | `org.scijava.common3.Strings`: `padStart`, `padEnd`, `isNullOrEmpty`, `splitUnquoted`. `sanitize`, `sanitizeDouble`, `stripNulls` and `samePrefix` are dropped — no usages anywhere | ported | +| `Timing` | on hold — pending evidence of downstream usage | open | +| `Types` | `org.scijava.common3.Types` + `org.scijava.common3.Classes` — **not** a drop-in superset, see below | ported | +| `UnitUtils` | dropped — one usage ecosystem-wide; add it back on demand | dropped | +| `VersionUtils` | `org.scijava.meta.Versions` | ported | +| `XML` | `org.scijava.meta.XML` | ported | + +### `util.Types` → `common3.Types` is not a superset + +| `util.Types` member | Where it went | +| --- | --- | +| `box`, `unbox`, `isBoolean`, `isByte`, `isCharacter`, `isDouble`, `isFloat`, `isInteger`, `isLong`, `isNumber`, `isShort`, `isText`, `nullValue` | `common3.Classes` | +| `field`, `fieldType` | `common3.Classes.field` | +| `args`, `enumFromLabel`, `enumFromString` | dropped (`enumValue` remains) | +| `commonSuperTypeOf`, `isRecursive`, `isRecursiveBound`, `varsSatisfied`, `unroll` | new in `common3.Types` | + +## Root package `org.scijava` + +| SJC class | Replacement | Status | +| --- | --- | --- | +| `Priority`, `Prioritized` | `org.scijava.priority.*` | ported | +| `Versioned` | `org.scijava.spi.Versioned` (moved out of `common3`) | ported | +| `Named` | `org.scijava.spi.Named` — read-only `String name()`; mutability is a separate contract | ported | +| `Identifiable` | `org.scijava.spi.Identifiable` — `getIdentifier()` is now `id()` | ported | +| `Disposable` | `org.scijava.spi.Disposable` — `dispose()` is abstract, not a no-op default | ported | +| `Locatable` | deferred — its only content was a default implementation needing `common3.Classes.location`, which `scijava-spi` cannot depend on | open | +| `Typed` | deferred to the plugin layer — no consumer yet, and `Class type()` collides with `GenericTyped.type()` | open | +| `Initializable` | `scijava-context` (Phase 2) — a lifecycle hook, not an SPI contract | planned | +| `Cancelable` | **not ported** — cancellation is thread interruption, and the client API is `Future.cancel(true)`. See the cancellation section of [migration-plan.md](migration-plan.md) | dropped | +| `ItemIO` | `org.scijava.struct.ItemIO` | ported | +| `ItemVisibility` | input harvesting (Phase 4) | open | +| `Context`, `NoSuchServiceException` | `org.scijava.context.Context` and `org.scijava.context.NoSuchServiceException`. Services are created lazily rather than all at once, and a context is `AutoCloseable` | ported | +| `Contextual`, `AbstractContextual`, `NullContextException` | dropped — a service receives its context in `Service.initialize(Context)`, so it need not carry one, and there is no null-context state to report | dropped | +| `Gateway`, `AbstractGateway`, `SciJava` | Phase 2, if retained at all | open | +| `Instantiable`, `InstantiableException` | subsumed by `Discovery` | planned | +| `BasicDetails`, `AbstractBasicDetails`, `UIDetails`, `AbstractUIDetails`, `MenuEntry`, `MenuPath` | `scijava-command3` presentation metadata, as attributes in the annotation index rather than an inheritance chain every plugin extends. **Done**: menu path, label, icon, accelerator, weight, visibility — carried by `@Menu`, exposed on `CommandInfo`, rendered by all three bindings. **Declared but not yet rendered**: `selectable` and `selectionGroup` reach the index and stop there. **Not migrated, by design**: `isSelected`/`isEnabled`, which in SciJava Common are *mutable setters on the description* — the same mutability that made `ModuleInfo` per-instance. They are application state rather than metadata; see the presentation-state section of [migration-plan.md](migration-plan.md). `menuRoot` awaits an application with more than one menu root | open | +| `Validated`, `ValidityProblem` | execution layer (Phase 3) | open | +| `Optional` | `scijava-optional` (satellite repo, moves in later) | open | + +## Subsystem packages + +| SJC package | Replacement | Status | +| --- | --- | --- | +| `annotations` | `org.scijava.index`: `Indexable`, `Index`, `IndexItem`, `IndexReader`, `AbstractIndexWriter`, `AnnotationProcessor`, `ByteCodeAnalyzer`, `DirectoryIndexer`, plus the new `IndexDiscoverer`. The index format (`META-INF/json/`) is unchanged, so indexes stay readable by both | ported | +| `annotations.EclipseHelper` | none — m2e `.factorypath` covers it; `DirectoryIndexer` is ported, so a Maven plugin can still index a directory | dropped | +| `annotations.AnnotationCombiner` | `scijava-maven-plugin` — it merges indexes for shaded JARs, which is build tooling | dropped | +| `annotations.legacy` | none — reads the pre-2013 `META-INF/annotations/` format | dropped | +| `io.location` | `org.scijava.io3.location`. `LocationService`/`DefaultLocationService` become `Locations`, backed by plain `ServiceLoader`; `LocationResolver` no longer extends `HandlerPlugin` and carries its own `priority()`. `URILocation`'s injected `LogService` was dead code and is gone | ported | +| `ByteBank`, `ByteArrayByteBank` | `org.scijava.io3` | ported | +| `io.handle` | `org.scijava.io3.handle`. `DataHandle` no longer extends `WrapperPlugin`: the contract was `getType()` plus `set`/`get`, now inlined as `locationType()`, `supports`, `set`, `get` and `priority()`. `DataHandleService` becomes `DataHandles`, backed by plain `ServiceLoader`. `DataHandles.copy` reports progress to a `LongConsumer` rather than a `Task`, and so no longer supports cancellation | ported | +| `io.nio.ByteBufferByteBank` | `org.scijava.io3.ByteBufferByteBank`, with `direct()` factories and an `isDirect()` accessor. Off-heap direct buffers are its reason to exist alongside `ByteArrayByteBank`: bytes reach native code and shared memory without a copy, which matters increasingly for Appose's named shared memory blocks. **Bug fixed in the port:** growing a direct bank reallocated with `ByteBuffer.allocate`, quietly moving the bytes back onto the heap; it now grows through the bank's own provider | ported | +| `io.nio.NIOService` | **not ported** — it wraps two JDK calls behind a system-property switch, was inherited from old Bio-Formats code, and SCIFIO only exposes it as a gateway accessor (`SCIFIO.nio()`) without using it. Nothing in the ported architecture precludes `FileChannel`-based handles later: a handle is free to open a channel, and a byte bank is free to be backed by a mapped buffer | dropped | +| `io` (`IOPlugin`, `IOService`, `RecentFileService`) | `scijava-io3` (Phase 1), pending review of what is still used | open | +| `event.EventHandler` | `org.scijava.context.EventHandler`, scanned by the container rather than the bus: a service's handlers are subscribed on creation and dropped on context dispose. The bus itself stays reflection-free | ported | +| `event`, `event.bushe` | `org.scijava.events`, a clean-room rewrite. `EventService` becomes `EventBus`, one per context and with no shared instance. `subscribe` takes a lambda rather than scanning `@EventHandler` methods, and returns a `Subscription`; subscribers are held strongly and dropped by `EventBus.close()`. Events need no `SciJavaEvent` base type. Consumption is opt-in via `Consumable`; subscriber order is by `scijava-priority` | ported | +| `event.EventHistory`, `DefaultEventHistory` | dropped — a subscriber on `Object.class` reproduces it. The macro-recorder need behind it deserves an explicit layer | dropped | +| `parse` | Parsington (`org.scijava.parsington`) | planned | +| `log` | SLF4J, used directly | dropped | +| `cache` | `scijava-cache` moves in, keeping `org.scijava.cache` (Phase 2) | planned | +| `thread` | `org.scijava.concurrent` | ported | +| `task` | `org.scijava.progress` | ported | +| `plugin.Parameter` (as dependency injection) | `org.scijava.context.Dependency`. Renamed because `@Parameter` meant both "inject this" and "this is a module input"; the latter is now `org.scijava.struct` | ported | +| `plugin` (`Plugin`, `Attr`, `PluginService`, `PluginInfo`, `PluginIndex`) | `org.scijava.context.Plugin` and `Attr`, discovered via `Context.plugins(Class)`. `PluginInfo` becomes `org.scijava.discovery.Discovery`, which reports class name, metadata and priority without loading the class; `PluginService` and `PluginIndex` are subsumed by `Context.plugins` | ported | +| `plugin` (the `HandlerPlugin`/`WrapperPlugin`/`TypedPlugin`/`SingletonPlugin` hierarchy and their services) | not ported — each user of these inlines the small contract it needs, as `DataHandle` did | dropped | +| `service` | `org.scijava.context`: `Service` (no longer a plugin, and discovered via `ServiceLoader`), with `Context` holding them. `SciJavaService` has no equivalent — a marker is not needed. `ServiceHelper` and `ServiceIndex` are internal to `Context` | ported | +| `object` | with the widgets and conversion layer that use it (Phase 3/4), so a consumer shapes it | planned | +| `prefs` | `org.scijava.settings`: one TOML file a person can read, edit and copy, at `~/.config//settings.toml` on every platform. `PrefService` becomes `Settings`, an ordinary object rather than a service or a singleton - settings belong to an application, and two applications in one JVM want two of them. A table per command (its identifier), a key per parameter; values map onto TOML's own types, so a `List` is an array of strings rather than one mangled string, and reading converts through `scijava-convert3`. Widget value persistence is `LoadInputs` and `SaveInputs`, both **preprocessors**: saving after the run would lose the values exactly when a run failed and the user is about to try again | ported | +| `app` | mostly `org.scijava.meta` for version and title metadata; the rest is app-shell material, dropped | planned | +| `thread` | EDT dispatch belongs with the UI layer (Phase 4); parallelism is `org.scijava.concurrent` | planned | +| `convert` | `org.scijava.convert3`: `Converter` asks one question - can you turn *this value* into *that type* - where SciJava Common had eight `canConvert` overloads and a `ConversionRequest` to carry the combinations. `ConvertService` becomes `Converters`, backed by plain `ServiceLoader` so that conversion sits *below* the container: parameter binding converts, and a script or command line supplies strings for everything. `DefaultConverter`'s pile of special cases becomes one converter per concern (cast, number, string, to-string, array, collection, constructor, file/path), each contributable and orderable by priority. `Converter.of(String.class, Foo.class, Foo::parse)` covers the common case in a line | ported | +| the legacy bridge | `org.scijava.compat`: `LegacyCommandInfo` presents a SciJava Common `ModuleInfo` as a SciJava3 `CommandInfo`, so every existing ImageJ2 command and Fiji plugin appears in SciJava3 menus and runs through the SciJava3 runner, harvester and dialogs unchanged. Service and context parameters are filtered out (SciJava Common's `@Parameter` meant both "input" and "inject"); `DynamicCommand`s work because the module's own description is read after initialization; callbacks and validators resolve by name through `behavior(String)`. The dependency goes one way and lives only here | ported | +| `module`, `module.process` | `org.scijava.execute`: `@Parameter` on fields, `Executables` for the plain case, and `Runner` for the processor chain. `ModuleService.run` becomes `Runner.run`, still returning a future, but of an `ExecutionResult` that reports a declined run with its reason and its author rather than through `ModulePreprocessor.isCanceled()` | ported | +| `command` | `org.scijava.command3`: `Command` is a `Runnable` whose `@Parameter` fields are its struct; `@Menu` (repeatable) carries the presentation metadata, read from the annotation index without loading the class. `CommandInfo` replaces `ModuleInfo` and needs no `Context` - a command is anything a user can run, whether its implementation is a class (`ClassCommandInfo`, read from the index) or a script; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | +| `script`, `script.process` | `org.scijava.script3`: `ScriptLanguage`/`ScriptSession` are the SPI, with `JsrLanguage` making every JSR-223 engine on the classpath available for free; GraalVM polyglot and Appose are further languages, not replacements. `ScriptInfo`/`ScriptModule` become `ScriptExecutable`, an ordinary `Executable`, so a script runs through the same `Runner`, preprocessors, dialogs and menus as a Java command. `ScriptService` has no equivalent: `Scripts.get().of(path)` is a line. The `#@` header syntax is unchanged, and declaration lines are blanked rather than removed so the language's error messages keep pointing at the right line. The processor chain (`ScriptProcessor` and friends) is not ported: it existed to rewrite scripts before evaluation, and parameters plus a shebang are all it was actually used for. `ScriptREPL`, `ScriptInterpreter`, `AutoCompleter` and `CodeGenerator` await the script editor. `ScriptFinder` is ported: it reads `#@script` directives and falls back to the directory hierarchy for a menu path, handing an application metadata rather than menus, since this component knows nothing about menus | ported | +| `widget` (`InputWidget`, `InputPanel`, `InputHarvester`, `WidgetModel`, `WidgetStyle`) | `org.scijava.ui3`: `Widget` is a view of a `ParameterNode`, `WidgetFactory` a discoverable `@Plugin`, `WidgetPanel` a widget holding widgets (so a group nests with no special case), and `WidgetPanels` walks the tree for every toolkit. `WidgetModel` has no equivalent - a widget reads the `ParameterModel` and the node directly, and `WidgetStyle`'s parsing is `Widgets.isStyle`/`styleValue`. `InputHarvester` is a `Preprocessor` that declines the run when the user dismisses the dialog, rather than setting a `canceled` flag on itself | ported | +| `ui.awt` (satellite repo `scijava-ui-awt`) | `org.scijava.awt`: the widget set in pure `java.awt` - no spinner, so the number widget is a field and a scroll bar - plus `Edt`, the first of the AWT platform layer that the Swing binding shares. `AWTColors`, `AWTWindows`, `AWTClipboard`, `AWTInputEventDispatcher` and the drag-and-drop dispatchers belong there too, when the `input`/`tool` layer arrives; the `AWTUI`/`AWTApplicationFrame`/`AWTStatusBar` shell classes await the application layer | ported | +| `ui.javafx` (satellite repo `scijava-ui-javafx`) | `org.scijava.javafx`: the same widget set as the Swing binding, plus `FxThread` for JavaFX's application thread. `JavaFXKeys`'s key-code mapping is worth keeping when the `input`/`tool` layer arrives; `JavaFXMenuCreator` is superseded by `FxMenus`, some 40 lines against `MenuCreator` | ported | +| `ui.swing.widget` | `org.scijava.swing`: number (spinner, with an optional slider or scroll bar and labelled ticks), text (field, password, area, and `BigInteger`/`BigDecimal`, which a spinner cannot step), toggle, choice, file, message, and the panel and dialog. `SpinnerBigDecimalModel`, `SpinnerBigIntegerModel`, `SpinnerTypedNumberModel` and `SpinnerNumberModelFactory` have no equivalent: `SpinnerNumberModel` already steps in the type of the value it holds, and arbitrary precision goes to the text widget. MigLayout is gone, in favor of `GridBagLayout`: one fewer dependency, and one fewer automatic module. The button, color, date, file-list and radio-choice widgets are not ported yet - each earns its place when something asks for it | ported | +| `menu` (`MenuCreator`, `AbstractMenuCreator`, `MenuService`, `ShadowMenu`) | `org.scijava.command3`: `CommandInfo` is anything a user can run (a menu path being optional), `MenuTree` arranges the subset that has one, and `MenuCreator`/`Menus` are the toolkit-free walk, with `SwingMenus`, `FxMenus` and `AwtMenus` implementing it. `MenuService` has no equivalent: a menu bar is `MenuTree.of(Commands.discover(context))`, which is a line rather than a service | ported | +| `ui` | `scijava-ui3` holds the toolkit-agnostic contracts, and the widget half is done. `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` are still to come, as contracts there with implementations in the toolkit bindings. Dropped from the *core*, not from the stack — see the layering section of [migration-plan.md](migration-plan.md) | planned | +| `input`, `tool` | UI-agnostic facades on `scijava-events` (Phase 4) | planned | +| `display` | overhauled, not ported (Phase 4) | open | +| `platform`, `ui.dnd` | `scijava-desktop` modernization (Phase 4) | planned | +| `console`, `main`, `run` | `org.scijava.cli`: one component where SciJava Common had three packages. `Cli.run(args)` finds a command by menu path, label or class name, binds `--option value` pairs as its inputs (converted by `scijava-convert3`, so a shell's strings reach typed parameters), runs it through the same `Runner` a dialog uses, and prints its outputs. `--list [text]` and `--help ` read the same metadata the menus do. `ConsoleService`/`ConsoleArgument` have no equivalent yet: the pluggable-argument mechanism earns its place when a component actually needs to add an argument. `CodeRunner`/`RunService` are subsumed - a script path is a command like any other. `MainService` has no equivalent: `main` is a method | ported | +| `startup` | folded into the application layer rather than ported: `StartupService` is a registry of hooks, and the thing actually missing is the assembly - gather commands from the index, from scripts, from the legacy bridge - which the three demo shells and the CLI each do for themselves (Phase 4) | planned | +| `welcome`, `download`, `text`, `options` | likely dropped | open | +| `minimaven`, `test` | out of scope | dropped | diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 000000000..418ff398e --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,139 @@ +# Versioning and compatibility + +How components in this repository are named, versioned and evolved, and why. +These principles were first worked out in the +[SciJava incubator](#history-the-scijava-incubator), which this repository +replaced; this document keeps the parts that still govern development here. + +Companion document: [migration-plan.md](migration-plan.md), which applies +these rules to the migration of SciJava Common. + +## The requirement: scripts keep working + +ImageJ and Fiji are not software products so much as living processes, and +their users expect a script written against the public API to keep working +for many years. At the same time, some bugs cannot be fixed, and some designs +cannot be improved, without changing that API. The goal is to have both: +downstream code keeps working, *and* the codebase can still improve. + +SemVer alone does not achieve this. A major version bump announces a break, +but it does not prevent one: a downstream component built against `2.x` fails +as soon as anything else on the classpath pulls in `3.x`, because only one +version of a given artifact can be loaded. Every component in the stack is +then forced to migrate in lockstep. + +## Incarnational versioning + +When a component needs a change that cannot be made compatibly, it is not +given a new major version. It is forked to a new **incarnation**: a new +artifactId *and* a new package prefix, both carrying the incarnation number. +For example, `scijava-common3` lives in `org.scijava.common3`, and a future +incompatible successor would be `scijava-common4` in `org.scijava.common4`. + +Because the two incarnations share no artifact and no package, both can be on +one classpath at once. Code written against the old incarnation keeps running +unchanged, while new code adopts the new one, and each downstream component +migrates on its own schedule rather than all at once. + +The rules: + +- **Never bump the major version within an incarnation.** If compatibility + must break, bump the incarnation digit instead: artifactId, package prefix + and major version together. +- **The major version equals the incarnation number**, so that + `scijava-io3` is at `3.x.y`, to avoid confusion about which number means + what. (The `1.x.y` of most components here is a legacy of the first + monoversioned `1.0.0` release, not the intended scheme; see + [migration-plan.md](migration-plan.md) for how it is being realigned.) +- **Deprecate before you fork.** Within an incarnation, API is retired by + deprecation, which keeps scripts working while pointing at the replacement. + A new incarnation is for changes that deprecation cannot express. +- **The number is the incarnation, not a collision workaround.** Where a new + package would collide with an existing one and no incarnation applies, pick + a natural, collision-free noun instead of inventing a digit. Every such + decision is recorded in [migration.md](migration.md). +- **Incarnations do not share data structures.** An object from `common3` is + not inherently usable where `common4` expects its counterpart. Bridging the + two is the job of an explicit compatibility layer, written when a concrete + need appears; apart from such layers, a component uses one incarnation of + each dependency at a time. +- **Fixes do not automatically cross incarnations.** Whether a fix is ported + forward or back is decided case by case. Keeping the period during which two + incarnations are both actively developed short keeps that cost small. + +### Only the system class loader + +An earlier approach +([forum post](https://forum.image.sc/t/8393)) isolated incompatible versions +with custom class loaders. That turned out to limit deployment: tools that +embed the JVM, such as Python bridges, might only be able to wrap classes from +the system class loader. Incarnations need nothing but distinct names, so they +work on a plain classpath, with no class loader tricks, for arbitrarily long. + +### Transitive dependencies do not cascade + +Suppose `imagej-common3` depends on `scijava-magic5`, and later moves to +`scijava-magic6`. Must `imagej-common3` itself become a new incarnation? Not as +long as no `scijava-magic5` type appears in its public API. A downstream tool +that uses both `scijava-magic5` and `imagej-common3` will simply end up with +both `scijava-magic5` and `scijava-magic6` on its classpath, and that is fine, +since they share nothing. + +This is what prevents a domino effect, where one incarnation bump forces bumps +in everything above it. It is also a strong reason to keep dependency types +out of public API. + +### Deprecated clutter + +Over time an incarnation accumulates deprecated classes. Moving them into a +separate `-deprecated` component is not an option, because the packages would +be split across two modules, which JPMS forbids. When the clutter becomes a +real problem, that is a sign the next incarnation is due. + +## Minimal API surface + +The smaller the public API, the less there is that can never change. So: + +- As few public classes and methods as possible, and interfaces wherever + possible. +- Every component is a JPMS module that exports only its API packages. + Implementation packages stay unexported. +- No code in a blanket package prefix such as the root `org.scijava` package. + Each component owns its own package prefix, which keeps packages from being + split across modules. + +## Release early, but stable + +The traditional mantra is "release early, release often". Its failure mode is +that a half-finished design gets released, gets adopted, and is then frozen by +the compatibility promise above. The refinement: release as early as possible, +but make that release one you are prepared to support indefinitely. Before +then, restructure freely; afterward, change compatibly or fork an incarnation. + +## Monoversioning per layer + +The core components of each layer of the stack live together in one +multi-module repository: this one, for SciJava. That gives one place to find +code and file issues, keeps tightly coupled modules building against each +other in CI, and makes it obvious which versions go together. Add-ons that +merely build on the core stay in their own repositories. + +The first release from this repository was a single monoversioned `1.0.0`, +because releasing a reactor whose modules have different versions needs +release tooling that did not yet exist. The intent is for each component to +carry its own version, with the major version following its incarnation, as +described above. + +## History: the SciJava incubator + +From 2020 until 2024, new core development happened in the +[SciJava incubator](https://github.com/scijava/incubator), a single +repository where immature components could be restructured freely at version +`0-SNAPSHOT`, breaking changes were caught across all of them by one CI build, +and each component graduated once ready for `1.0.0`. The incubator was where +SciJava Ops and the other components here took shape, and it is where the +principles above were first written down. + +With everything graduated into this repository, the incubator is retired and +archived. Its one component that never graduated, `scijava-persist`, is +discussed in [migration-plan.md](migration-plan.md). diff --git a/events/pom.xml b/events/pom.xml new file mode 100644 index 000000000..f27e59fe5 --- /dev/null +++ b/events/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-events + 1.0.0-SNAPSHOT + + SciJava Events + A typed event bus for loosely coupled notification. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.events + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/events/src/main/java/module-info.java b/events/src/main/java/module-info.java new file mode 100644 index 000000000..91a824c96 --- /dev/null +++ b/events/src/main/java/module-info.java @@ -0,0 +1,36 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.events { + + exports org.scijava.events; + + requires transitive org.scijava.priority; + +} diff --git a/events/src/main/java/org/scijava/events/Consumable.java b/events/src/main/java/org/scijava/events/Consumable.java new file mode 100644 index 000000000..bbfb62004 --- /dev/null +++ b/events/src/main/java/org/scijava/events/Consumable.java @@ -0,0 +1,59 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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.events; + +/** + * An event that a handler can mark as handled, stopping its delivery. + *

+ * This is opt-in: an event type implements it only if consumption makes sense + * for it, and every other event is delivered to all subscribers regardless. + * The model is {@code java.awt.event.InputEvent.consume()} - named in prose, + * not linked, so that this component need not require {@code java.desktop} - + * so that + * toolkit-style input events - where one handler claims a keystroke and the + * rest must not see it - can be expressed without every event paying for the + * concept. + *

+ *

+ * Delivery order is therefore meaningful for consumable events: subscribers + * run in priority order, so a higher-priority handler gets first refusal. + *

+ * + * @author Curtis Rueden + * @see EventBus#publish(Object) + */ +public interface Consumable { + + /** Marks this event handled, so that remaining subscribers do not see it. */ + void consume(); + + /** Gets whether this event has been consumed. */ + boolean isConsumed(); +} diff --git a/events/src/main/java/org/scijava/events/DefaultEventBus.java b/events/src/main/java/org/scijava/events/DefaultEventBus.java new file mode 100644 index 000000000..3fe655034 --- /dev/null +++ b/events/src/main/java/org/scijava/events/DefaultEventBus.java @@ -0,0 +1,165 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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.events; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * Default {@link EventBus} implementation. + * + * @author Curtis Rueden + */ +public class DefaultEventBus implements EventBus { + + private final List> registrations = new CopyOnWriteArrayList<>(); + + /** Resolved subscriber lists, keyed by published event class. */ + private final Map, List>> cache = + new ConcurrentHashMap<>(); + + /** Ties in priority are broken by subscription order. */ + private final AtomicLong sequence = new AtomicLong(); + + @Override + public Subscription subscribe(final Class eventType, + final double priority, final Consumer handler) + { + if (eventType == null) throw new NullPointerException("eventType"); + if (handler == null) throw new NullPointerException("handler"); + final Registration registration = new Registration<>(eventType, // + priority, sequence.getAndIncrement(), handler); + registrations.add(registration); + cache.clear(); + return registration; + } + + @Override + public void publish(final Object event) { + if (event == null) throw new NullPointerException("event"); + final Consumable consumable = event instanceof Consumable // + ? (Consumable) event : null; + + List failures = null; + for (final Registration registration : subscribersFor(event + .getClass())) + { + if (registration.isClosed()) continue; + if (consumable != null && consumable.isConsumed()) break; + try { + registration.deliver(event); + } + catch (final CancellationException exc) { + // NB: this thread is being cancelled. Stop at once rather than + // pressing on and burying it among unrelated failures. + throw exc; + } + catch (final Throwable exc) { + if (Thread.currentThread().isInterrupted()) throw exc; + if (failures == null) failures = new ArrayList<>(); + failures.add(exc); + } + } + if (failures != null) throw new EventDeliveryException(event, failures); + } + + @Override + public void close() { + // NB: mark each closed, so that a delivery already under way stops + // calling them, then drop the references. + registrations.forEach(Registration::close); + registrations.clear(); + cache.clear(); + } + + /** Gets the subscribers for an event class, in delivery order. */ + private List> subscribersFor(final Class eventClass) { + return cache.computeIfAbsent(eventClass, c -> { + final List> matches = new ArrayList<>(); + for (final Registration r : registrations) { + // NB: subtypes count, so a subscriber to the supertype hears it. + if (r.eventType.isAssignableFrom(c)) matches.add(r); + } + matches.sort(Comparator + .comparingDouble((Registration r) -> r.priority).reversed() // + .thenComparingLong(r -> r.sequence)); + return matches; + }); + } + + /** One subscription. */ + private class Registration implements Subscription { + + private final Class eventType; + private final double priority; + private final long sequence; + private final Consumer handler; + private final AtomicBoolean closed = new AtomicBoolean(); + + Registration(final Class eventType, final double priority, + final long sequence, final Consumer handler) + { + this.eventType = eventType; + this.priority = priority; + this.sequence = sequence; + this.handler = handler; + } + + void deliver(final Object event) { + handler.accept(eventType.cast(event)); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + registrations.remove(this); + cache.clear(); + } + } + + @Override + public boolean isClosed() { + return closed.get(); + } + + @Override + public String toString() { + return eventType.getName() + " @ " + priority; + } + } +} diff --git a/events/src/main/java/org/scijava/events/EventBus.java b/events/src/main/java/org/scijava/events/EventBus.java new file mode 100644 index 000000000..ebf3baf34 --- /dev/null +++ b/events/src/main/java/org/scijava/events/EventBus.java @@ -0,0 +1,127 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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.events; + +import java.util.function.Consumer; + +import org.scijava.priority.Priority; + +/** + * Notifies subscribers of events, without publisher and subscriber knowing + * about each other. + *

+ * A subscription names the event type it wants, and events reach subscribers + * of that type and of its supertypes: publishing a + * {@code DisplayUpdatedEvent} reaches subscribers of {@code DisplayEvent}. An + * event may be any object; no marker interface is required, so a type from + * another library can be an event. + *

+ *

+ * There is deliberately no shared instance. A bus is nothing + * but shared mutable subscriber state, so an ambient singleton would let two + * application contexts hear each other's events. Each context owns one bus, + * and code reaches it through its context; standalone callers construct their + * own with {@link #create()}. + *

+ *

+ * Subscribers are held by strong reference. A subscription ends when its + * {@link Subscription} is closed, or when the whole bus is {@link #close()}d - + * which a context does when it is disposed, so that code registering handlers + * on behalf of a service never has to think about cleanup. + *

+ * + * @author Curtis Rueden + */ +public interface EventBus extends AutoCloseable { + + /** + * Subscribes to events of the given type, at {@link Priority#NORMAL}. + * + * @param eventType the type of event to receive, subtypes included + * @param handler what to do with each event + * @return a handle that cancels the subscription + */ + default Subscription subscribe(final Class eventType, + final Consumer handler) + { + return subscribe(eventType, Priority.NORMAL, handler); + } + + /** + * Subscribes to events of the given type. + *

+ * Subscribers run in descending priority order, ties broken by + * subscription order. Priority matters most for {@link Consumable} events, + * where an earlier subscriber can stop delivery to the rest. + *

+ * + * @param eventType the type of event to receive, subtypes included + * @param priority the subscriber's priority; see {@link Priority} + * @param handler what to do with each event + * @return a handle that cancels the subscription + */ + Subscription subscribe(Class eventType, double priority, + Consumer handler); + + /** + * Delivers an event to its subscribers, on the calling thread. + *

+ * Delivery stops early only if the event is {@link Consumable} and a + * subscriber consumes it. A subscriber that throws does not stop delivery + * to the others; the failures are gathered and thrown together afterward as + * an {@link EventDeliveryException}. + *

+ *

+ * Cancellation is the exception to that: if a subscriber is interrupted, or + * throws {@link java.util.concurrent.CancellationException}, delivery stops + * at once and the exception propagates. An interrupt means this thread is + * being cancelled, and must not be buried among other failures. + *

+ * + * @param event the event to deliver + * @throws EventDeliveryException if any subscriber failed + */ + void publish(Object event); + + /** + * Cancels every subscription on this bus. + *

+ * A context calls this when disposing, which is what lets services + * subscribe without arranging their own cleanup. + *

+ */ + @Override + void close(); + + /** Creates a bus. */ + static EventBus create() { + return new DefaultEventBus(); + } +} diff --git a/events/src/main/java/org/scijava/events/EventDeliveryException.java b/events/src/main/java/org/scijava/events/EventDeliveryException.java new file mode 100644 index 000000000..1ae512b24 --- /dev/null +++ b/events/src/main/java/org/scijava/events/EventDeliveryException.java @@ -0,0 +1,67 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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.events; + +import java.util.List; + +/** + * Reports that one or more subscribers failed while handling an event. + *

+ * Delivery does not stop when a subscriber throws: every remaining subscriber + * still sees the event, and the failures are reported together afterward. One + * broken subscriber therefore cannot quietly truncate delivery to the others, + * and no failure is swallowed. + *

+ * + * @author Curtis Rueden + */ +public class EventDeliveryException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient Object event; + + public EventDeliveryException(final Object event, + final List failures) + { + super(failures.size() + " subscriber(s) failed handling " + // + (event == null ? "null" : event.getClass().getName()), failures.get(0)); + this.event = event; + // NB: the first failure is the cause; the rest ride along, so that none + // is lost. + for (int i = 1; i < failures.size(); i++) + addSuppressed(failures.get(i)); + } + + /** Gets the event whose delivery failed. */ + public Object event() { + return event; + } +} diff --git a/events/src/main/java/org/scijava/events/Subscription.java b/events/src/main/java/org/scijava/events/Subscription.java new file mode 100644 index 000000000..ebb3dca8d --- /dev/null +++ b/events/src/main/java/org/scijava/events/Subscription.java @@ -0,0 +1,57 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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.events; + +/** + * A handle on one subscription to an {@link EventBus}. + *

+ * Subscribers are held by strong reference, so a subscription lasts until it + * is closed - there is no silent disappearance when the garbage collector + * decides a handler is unreachable. Closing is idempotent, and an + * {@link AutoCloseable} so that a scoped subscription can be a + * try-with-resources. + *

+ *

+ * Most code never closes one itself: a container that registers handlers on + * behalf of an object keeps the subscriptions and closes them when that object + * is disposed. + *

+ * + * @author Curtis Rueden + */ +public interface Subscription extends AutoCloseable { + + /** Cancels this subscription. Calling it more than once does nothing. */ + @Override + void close(); + + /** Gets whether this subscription has been closed. */ + boolean isClosed(); +} diff --git a/events/src/test/java/org/scijava/events/EventBusTest.java b/events/src/test/java/org/scijava/events/EventBusTest.java new file mode 100644 index 000000000..75cca5cfc --- /dev/null +++ b/events/src/test/java/org/scijava/events/EventBusTest.java @@ -0,0 +1,310 @@ +/* + * #%L + * A typed event bus for loosely coupled notification. + * %% + * Copyright (C) 2026 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.events; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; + +import org.junit.jupiter.api.Test; +import org.scijava.priority.Priority; + +/** + * Tests {@link DefaultEventBus}. + * + * @author Curtis Rueden + */ +public class EventBusTest { + + // -- Test events -- + + private static class BaseEvent {} + + private static class DerivedEvent extends BaseEvent {} + + private static class Keystroke implements Consumable { + + private boolean consumed; + + @Override + public void consume() { + consumed = true; + } + + @Override + public boolean isConsumed() { + return consumed; + } + } + + @Test + public void testPublishAndSubscribe() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(BaseEvent.class, seen::add); + + final BaseEvent event = new BaseEvent(); + bus.publish(event); + assertEquals(List.of(event), seen); + } + + /** A subscriber to a supertype hears subtypes. */ + @Test + public void testHierarchyDispatch() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(BaseEvent.class, e -> seen.add("base")); + bus.subscribe(DerivedEvent.class, e -> seen.add("derived")); + bus.subscribe(Object.class, e -> seen.add("object")); + + bus.publish(new DerivedEvent()); + assertEquals(3, seen.size()); + + seen.clear(); + bus.publish(new BaseEvent()); + // NB: the derived subscriber must not hear a supertype event. + assertEquals(List.of("base", "object"), seen); + } + + /** Any object can be an event; no marker interface is required. */ + @Test + public void testEventNeedsNoMarkerType() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(String.class, seen::add); + bus.publish("hello"); + assertEquals(List.of("hello"), seen); + } + + @Test + public void testPriorityOrder() { + final EventBus bus = EventBus.create(); + final List order = new ArrayList<>(); + bus.subscribe(BaseEvent.class, Priority.LOW, e -> order.add("low")); + bus.subscribe(BaseEvent.class, Priority.VERY_HIGH, e -> order.add("very high")); + bus.subscribe(BaseEvent.class, e -> order.add("normal")); + bus.subscribe(BaseEvent.class, Priority.HIGH, e -> order.add("high")); + + bus.publish(new BaseEvent()); + assertEquals(List.of("very high", "high", "normal", "low"), order); + } + + /** Ties in priority are broken by subscription order. */ + @Test + public void testSubscriptionOrderBreaksTies() { + final EventBus bus = EventBus.create(); + final List order = new ArrayList<>(); + bus.subscribe(BaseEvent.class, e -> order.add("first")); + bus.subscribe(BaseEvent.class, e -> order.add("second")); + bus.subscribe(BaseEvent.class, e -> order.add("third")); + + bus.publish(new BaseEvent()); + assertEquals(List.of("first", "second", "third"), order); + } + + /** A consumed event stops reaching later subscribers. */ + @Test + public void testConsumptionStopsDelivery() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(Keystroke.class, Priority.HIGH, e -> { + seen.add("high"); + e.consume(); + }); + bus.subscribe(Keystroke.class, Priority.LOW, e -> seen.add("low")); + + bus.publish(new Keystroke()); + assertEquals(List.of("high"), seen); + } + + /** An event that nobody consumes reaches everyone. */ + @Test + public void testUnconsumedEventReachesAll() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(Keystroke.class, Priority.HIGH, e -> seen.add("high")); + bus.subscribe(Keystroke.class, Priority.LOW, e -> seen.add("low")); + + bus.publish(new Keystroke()); + assertEquals(List.of("high", "low"), seen); + } + + /** + * One broken subscriber must not truncate delivery, and no failure may be + * lost. + */ + @Test + public void testFailuresAreAggregated() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(BaseEvent.class, Priority.VERY_HIGH, e -> { + throw new IllegalStateException("first"); + }); + bus.subscribe(BaseEvent.class, Priority.HIGH, e -> seen.add("survivor")); + bus.subscribe(BaseEvent.class, Priority.LOW, e -> { + throw new UnsupportedOperationException("second"); + }); + + final EventDeliveryException exc = assertThrows( + EventDeliveryException.class, () -> bus.publish(new BaseEvent())); + + // Everyone still got the event. + assertEquals(List.of("survivor"), seen); + // Neither failure was lost. + assertEquals("first", exc.getCause().getMessage()); + assertEquals(1, exc.getSuppressed().length); + assertEquals("second", exc.getSuppressed()[0].getMessage()); + } + + /** Cancellation is not an ordinary failure: it stops delivery at once. */ + @Test + public void testCancellationStopsDeliveryImmediately() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(BaseEvent.class, Priority.HIGH, e -> { + throw new CancellationException("cancelled"); + }); + bus.subscribe(BaseEvent.class, Priority.LOW, e -> seen.add("later")); + + assertThrows(CancellationException.class, () -> bus.publish( + new BaseEvent())); + assertTrue(seen.isEmpty(), "delivery continued past a cancellation"); + } + + @Test + public void testUnsubscribe() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + final Subscription subscription = bus.subscribe(BaseEvent.class, seen::add); + + bus.publish(new BaseEvent()); + assertEquals(1, seen.size()); + + subscription.close(); + assertTrue(subscription.isClosed()); + bus.publish(new BaseEvent()); + assertEquals(1, seen.size(), "a closed subscription still received events"); + + // Closing twice is harmless. + subscription.close(); + } + + /** Closing the bus is what a context does on dispose. */ + @Test + public void testCloseDropsEverySubscription() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + final Subscription a = bus.subscribe(BaseEvent.class, seen::add); + final Subscription b = bus.subscribe(Object.class, seen::add); + + bus.close(); + assertTrue(a.isClosed()); + assertTrue(b.isClosed()); + + bus.publish(new BaseEvent()); + assertTrue(seen.isEmpty(), "a closed bus still delivered events"); + } + + /** + * Two buses are wholly independent, which is how two application contexts + * keep their events straight. It is also why there is no shared instance. + */ + @Test + public void testBusesAreIsolated() { + final EventBus first = EventBus.create(); + final EventBus second = EventBus.create(); + final List heardByFirst = new ArrayList<>(); + final List heardBySecond = new ArrayList<>(); + first.subscribe(BaseEvent.class, heardByFirst::add); + second.subscribe(BaseEvent.class, heardBySecond::add); + + first.publish(new BaseEvent()); + assertEquals(1, heardByFirst.size()); + assertTrue(heardBySecond.isEmpty(), "events crossed between buses"); + + // Closing one must not disturb the other. + first.close(); + second.publish(new BaseEvent()); + assertEquals(1, heardBySecond.size()); + } + + /** Subscribing during delivery must not disturb the delivery under way. */ + @Test + public void testSubscribeDuringDelivery() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + bus.subscribe(BaseEvent.class, e -> { + seen.add("first"); + bus.subscribe(BaseEvent.class, e2 -> seen.add("added")); + }); + + bus.publish(new BaseEvent()); + assertEquals(List.of("first"), seen); + + // The late subscriber takes effect from the next event. + seen.clear(); + bus.publish(new BaseEvent()); + assertEquals(List.of("first", "added"), seen); + } + + @Test + public void testNullsAreRejected() { + final EventBus bus = EventBus.create(); + assertThrows(NullPointerException.class, () -> bus.publish(null)); + assertThrows(NullPointerException.class, () -> bus.subscribe(null, + e -> {})); + assertThrows(NullPointerException.class, () -> bus.subscribe( + BaseEvent.class, null)); + } + + /** + * A handler that cancels another subscription mid-delivery must stop that + * subscriber from receiving the event in flight. + */ + @Test + public void testUnsubscribeDuringDelivery() { + final EventBus bus = EventBus.create(); + final List seen = new ArrayList<>(); + final Subscription[] later = new Subscription[1]; + bus.subscribe(BaseEvent.class, Priority.HIGH, e -> { + seen.add("first"); + later[0].close(); + }); + later[0] = bus.subscribe(BaseEvent.class, Priority.LOW, // + e -> seen.add("later")); + + bus.publish(new BaseEvent()); + assertEquals(List.of("first"), seen); + } +} diff --git a/execute/pom.xml b/execute/pom.xml new file mode 100644 index 000000000..ab603607f --- /dev/null +++ b/execute/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-execute + 1.0.0-SNAPSHOT + + SciJava Execute + Running things that declare their inputs and outputs. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.execute + + bsd_2 + SciJava developers. + + + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-convert3 + ${scijava-convert3.version} + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/execute/src/main/java/module-info.java b/execute/src/main/java/module-info.java new file mode 100644 index 000000000..6f7efb75a --- /dev/null +++ b/execute/src/main/java/module-info.java @@ -0,0 +1,39 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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% + */ + +open module org.scijava.execute { + + exports org.scijava.execute; + + requires org.scijava.common3; + requires transitive org.scijava.priority; + requires transitive org.scijava.struct; + requires transitive org.scijava.convert3; + +} diff --git a/execute/src/main/java/org/scijava/execute/Behavior.java b/execute/src/main/java/org/scijava/execute/Behavior.java new file mode 100644 index 000000000..64eb7f25e --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Behavior.java @@ -0,0 +1,59 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +/** + * A named piece of behavior belonging to an executable: a callback run when a + * parameter changes, a validator, a function generating parameters. + *

+ * The point of naming behavior rather than reflecting a Java method is that a + * script has no Java methods. SciJava Common's callbacks, initializers and + * validaters were method-name strings resolved through Java reflection, which + * is exactly why they were never finished for scripts. An + * {@link ExecutableInstance} resolves a name however its own kind of code + * does: a Java class by reflecting a method, a script by asking its engine for + * a function. + *

+ * + * @author Curtis Rueden + * @see ExecutableInstance#behavior(String) + */ +@FunctionalInterface +public interface Behavior { + + /** + * Invokes this behavior. + * + * @param args the arguments, if it takes any + * @return whatever it returns, or null + * @throws BehaviorException if it fails + */ + Object invoke(Object... args); +} diff --git a/execute/src/main/java/org/scijava/execute/BehaviorException.java b/execute/src/main/java/org/scijava/execute/BehaviorException.java new file mode 100644 index 000000000..c3d300f83 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/BehaviorException.java @@ -0,0 +1,44 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +/** Thrown when a named {@link Behavior} cannot be invoked, or fails. */ +public class BehaviorException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public BehaviorException(final String message) { + super(message); + } + + public BehaviorException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/execute/src/main/java/org/scijava/execute/Executable.java b/execute/src/main/java/org/scijava/execute/Executable.java new file mode 100644 index 000000000..780e49a07 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Executable.java @@ -0,0 +1,68 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import org.scijava.struct.Struct; + +/** + * A description of something runnable: what parameters it has, and how to + * make one ready to run. + *

+ * This is deliberately not "a Java class with annotated fields". A + * script declares its parameters in its header, and an entry in a catalogue + * may declare them in a file; both are executables, and neither has an + * {@code @Parameter} field anywhere. Keeping this interface free of Java + * reflection is what admits them. + *

+ *

+ * NB: SciJava Common called this {@code ModuleInfo}. "Module" now means a JPMS + * module, so the name had to change regardless of taste. + *

+ * + * @author Curtis Rueden + * @see Executables#of(Class) + */ +public interface Executable { + + /** An identifier for this executable, unique among its kind. */ + String name(); + + /** Describes the parameters: their names, types and direction. */ + Struct struct(); + + /** + * Creates one ready to run. + *

+ * Each call yields a fresh instance: parameter values are per-run state, so + * two concurrent runs must not share one. + *

+ */ + ExecutableInstance create(); +} diff --git a/execute/src/main/java/org/scijava/execute/ExecutableInstance.java b/execute/src/main/java/org/scijava/execute/ExecutableInstance.java new file mode 100644 index 000000000..dad88e97d --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/ExecutableInstance.java @@ -0,0 +1,72 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.util.Optional; + +import org.scijava.struct.StructInstance; + +/** + * One instance of an {@link Executable}, whose parameters can be set and which + * can then be run. + *

+ * NB: SciJava Common called this {@code Module}. + *

+ * + * @author Curtis Rueden + */ +public interface ExecutableInstance { + + /** Gets what this is an instance of. */ + Executable executable(); + + /** Gets the parameters, bound to their values. */ + StructInstance parameters(); + + /** Runs it, with whatever values its parameters currently hold. */ + void run(); + + /** + * Looks up a named piece of behavior belonging to this instance - a + * callback, a validator, a function generating parameters. + *

+ * A Java executable resolves the name by reflecting a method; a script + * resolves it by asking its engine for a function of that name. Naming + * behavior rather than reflecting a method is what lets both work, where + * SciJava Common's callbacks were Java-only. + *

+ * + * @param name the behavior's name + * @return the behavior, or empty if this instance has none by that name + */ + default Optional behavior(final String name) { + return Optional.empty(); + } +} diff --git a/execute/src/main/java/org/scijava/execute/Executables.java b/execute/src/main/java/org/scijava/execute/Executables.java new file mode 100644 index 000000000..aa63f197b --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Executables.java @@ -0,0 +1,302 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; + +import org.scijava.common3.Types; +import org.scijava.convert3.Converters; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; +import org.scijava.struct.Structs; + +/** + * Runs objects that declare their inputs and outputs with {@link Parameter}. + *

+ * This is the SciJava Common module layer, rebuilt on {@code scijava-struct}: + * a class describes its inputs and outputs as annotated fields, a caller + * supplies values by name, and the outputs come back by name. + *

+ * + * @author Curtis Rueden + */ +public final class Executables { + + private static final FieldParameterMemberParser PARSER = + new FieldParameterMemberParser(); + + private Executables() { + // NB: prevent instantiation of utility class. + } + + /** + * Describes the given class as an {@link Executable}. + * + * @param type a class whose {@link Parameter} fields declare its parameters + * @return a description of it + */ + public static Executable of(final Class type) { + return new JavaExecutable(type); + } + + /** + * Describes the given class as an {@link Executable}, reflecting into it + * through the given lookup. + * + * @param type a class whose {@link Parameter} fields declare its parameters + * @param lookup a lookup with private access to it, as + * {@code Context.privateLookupIn} provides + * @return a description of it + */ + public static Executable of(final Class type, + final java.lang.invoke.MethodHandles.Lookup lookup) + { + return new JavaExecutable(type, lookup); + } + + /** + * Describes an already-constructed object as an {@link Executable} whose + * {@code create} yields that same object. + *

+ * NB: unlike {@link #of(Class)}, this cannot give each run its own + * instance, so two concurrent runs would share parameter values. It exists + * for the simple case of running an object one has in hand. + *

+ */ + public static Executable executableOf(final Runnable object) { + return executableOf(object, null); + } + + /** + * Describes an already-constructed object as an {@link Executable}, + * reflecting into it through the given lookup. + * + * @param object the object to run + * @param lookup a lookup with private access to its class, as + * {@code Access.lookupIn} provides, or null to reflect with this + * module's own access + * @see #executableOf(Runnable) + */ + public static Executable executableOf(final Runnable object, + final java.lang.invoke.MethodHandles.Lookup lookup) + { + final Struct struct = struct(object.getClass(), lookup); + return new Executable() { + + @Override + public String name() { + return object.getClass().getName(); + } + + @Override + public Struct struct() { + return struct; + } + + @Override + public ExecutableInstance create() { + final StructInstance parameters = struct.createInstance(object); + return new ExecutableInstance() { + + @Override + public Executable executable() { + return Executables.executableOf(object, lookup); + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + object.run(); + } + + @Override + public java.util.Optional behavior(final String name) { + return JavaExecutable.behaviorOf(object.getClass(), lookup, object, + name); + } + }; + } + }; + } + + /** + * Converts a supplied value to the type its parameter wants. + *

+ * NB: this is why conversion sits below the execution layer rather than + * beside it. A caller supplies whatever it has - a script has strings, a + * command line has strings, a user interface has whatever its widget + * produced - and the parameter's declared type is the authority on what + * that should become. + *

+ * + * @throws IllegalArgumentException if the value cannot become that type + */ + private static Object convert(final Object value, final Type type, + final String key) + { + if (type == null) return value; + return Converters.get().tryConvert(value, type).orElseThrow( + () -> new IllegalArgumentException("Cannot use " + // + (value == null ? "null" : value.getClass().getName()) + // + " as parameter '" + key + "' of type " + Types.name(type))); + } + + /** Describes the inputs and outputs of the given class. */ + public static Struct struct(final Class type) { + return struct(type, null); + } + + /** + * Describes the inputs and outputs of the given class, reflecting into it + * through the given lookup. + */ + public static Struct struct(final Class type, + final java.lang.invoke.MethodHandles.Lookup lookup) + { + return Structs.from(type, type, lookup == null ? PARSER // + : new FieldParameterMemberParser(lookup)); + } + + /** + * Runs the given object, supplying the named inputs and returning the + * outputs. + *

+ * Cancellation is thread interruption, as everywhere else: an interrupted + * run throws {@link CancellationException} rather than returning partial + * outputs. Submit to an {@link java.util.concurrent.ExecutorService} and + * cancel the {@link java.util.concurrent.Future} to use it. + *

+ * + * @param executable the object to run + * @param inputs values for its input parameters, by name + * @return its output parameters, by name + * @throws IllegalArgumentException if a required input is missing, or an + * input is not a parameter of this object. NB: a required input + * must be supplied here even if the field already holds a value, + * since a primitive field always holds one + */ + public static Map run(final Runnable executable, + final Map inputs) + { + final StructInstance instance = bind(struct(executable.getClass()) + .createInstance(executable), inputs, true); + checkCancellation(); + executable.run(); + checkCancellation(); + return outputs(instance); + } + + /** + * Gets a {@link Callable} that runs the given object, for submission to an + * executor. + * + * @param executable the object to run + * @param inputs values for its input parameters, by name + * @return a callable yielding its output parameters, by name + */ + public static Callable> callable( + final Runnable executable, final Map inputs) + { + return () -> run(executable, inputs); + } + + /** + * Binds the given input values onto the object's parameters. + * + * @param requireInputs whether to insist that required inputs be supplied + * now. A {@link Runner} passes false, since a preprocessor may + * supply them later. + */ + static StructInstance bind(final StructInstance instance, + final Map inputs, final boolean requireInputs) + { + final Struct struct = instance.struct(); + + for (final String key : inputs.keySet()) { + if (instance.member(key) == null) { + throw new IllegalArgumentException("No such parameter: " + key + // + ". This object declares " + names(struct)); + } + } + for (final MemberInstance member : instance.members()) { + if (!member.member().isInput()) continue; + final String key = member.member().key(); + if (inputs.containsKey(key)) { + member.set(convert(inputs.get(key), member.member().type(), key)); + } + else if (requireInputs && member.member().isRequired()) { + // NB: require the value to be supplied, rather than checking + // whether the field is null. A primitive field is never null -- a + // missing required `double` would silently run as 0.0. + throw new IllegalArgumentException("Missing required parameter: " + // + key); + } + } + return instance; + } + + /** Collects the object's output values. */ + static Map outputs(final StructInstance instance) { + final Map outputs = new LinkedHashMap<>(); + for (final MemberInstance member : instance.members()) { + if (member.member().isOutput()) outputs.put(member.member().key(), // + member.get()); + } + return outputs; + } + + private static String names(final Struct struct) { + final StringBuilder sb = new StringBuilder(); + for (final Member member : struct) { + if (sb.length() > 0) sb.append(", "); + sb.append(member.key()); + } + return sb.length() == 0 ? "no parameters" : sb.toString(); + } + + static void checkCancellation() { + // NB: cancellation is thread interruption; restore the flag so callers + // further up still see it. + if (Thread.interrupted()) { + Thread.currentThread().interrupt(); + throw new CancellationException("Execution cancelled"); + } + } +} diff --git a/execute/src/main/java/org/scijava/execute/Execution.java b/execute/src/main/java/org/scijava/execute/Execution.java new file mode 100644 index 000000000..25c22d4f3 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Execution.java @@ -0,0 +1,99 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.util.Map; +import java.util.Optional; + +import org.scijava.struct.StructInstance; + +/** + * One run in progress, handed to each {@link Preprocessor} and + * {@link Postprocessor}. + * + * @author Curtis Rueden + */ +public interface Execution { + + /** Gets the thing being run, whose parameters and behaviors it carries. */ + ExecutableInstance executable(); + + /** Gets its parameters, bound to their values. */ + StructInstance instance(); + + /** + * Declines this run: it will not happen, and the result will say so. + *

+ * This is how a preprocessor reports that the user cancelled a dialog, or + * that a precondition is not met. It is not an error, and it is not + * {@linkplain java.util.concurrent.Future#cancel(boolean) cancellation} of + * work already under way - nothing has run yet. + *

+ *

+ * NB: SciJava Common put this on the preprocessor itself, as + * {@code isCanceled()}/{@code getCancelReason()}, which made preprocessors + * stateful and unsafe to share between runs. Telling the execution instead + * keeps them stateless, and lets the result record which + * preprocessor declined, even where two instances of one class are in the + * same chain. + *

+ * + * @param reason why this run should not happen, for the user to read + */ + void decline(String reason); + + /** Gets whether this run has been declined. */ + boolean isDeclined(); + + /** Gets why this run was declined, if it was. */ + Optional reason(); + + /** + * Gets whether the caller supplied this input. + *

+ * NB: not the same question as whether the parameter holds a value, and the + * difference matters. A {@code double} always holds one, so a preprocessor + * filling in "empty" parameters by looking for nulls would never fill a + * primitive - and one that overwrote them regardless would throw away what + * the caller passed. SciJava Common answered this with a mutable + * {@code resolved} flag on each parameter; here the run knows what it was + * given, so it is asked instead. + *

+ * + * @param key the parameter's name + */ + boolean isSupplied(String key); + + /** + * Gets the outputs produced so far. Empty until the object has run, so this + * is for postprocessors. + */ + Map outputs(); +} diff --git a/execute/src/main/java/org/scijava/execute/ExecutionResult.java b/execute/src/main/java/org/scijava/execute/ExecutionResult.java new file mode 100644 index 000000000..344f82a53 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/ExecutionResult.java @@ -0,0 +1,110 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +/** + * What came of a run: its outputs, or why it did not happen. + *

+ * NB: this is deliberately not itself a {@link java.util.concurrent.Future}. + * A future's {@code isCancelled()} and {@code get()} are coupled by contract - + * whenever the former is true the latter must throw - so a declined run + * modelled as a cancelled future would be an exception again, which is what + * returning a result avoids. The asynchrony lives in + * {@code Future}, and what happened lives here. + *

+ * + * @author Curtis Rueden + */ +public class ExecutionResult { + + private final Map outputs; + private final String reason; + private final Preprocessor declinedBy; + + private ExecutionResult(final Map outputs, + final String reason, final Preprocessor declinedBy) + { + this.outputs = outputs; + this.reason = reason; + this.declinedBy = declinedBy; + } + + /** Creates the result of a run that happened. */ + public static ExecutionResult completed(final Map outputs) { + return new ExecutionResult(Map.copyOf(outputs), null, null); + } + + /** Creates the result of a run that was declined before it happened. */ + public static ExecutionResult declined(final String reason, + final Preprocessor declinedBy) + { + return new ExecutionResult(Collections.emptyMap(), reason, declinedBy); + } + + /** Gets whether the run happened. */ + public boolean isCompleted() { + return declinedBy == null && reason == null; + } + + /** Gets whether the run was declined before it happened. */ + public boolean isDeclined() { + return !isCompleted(); + } + + /** Gets why the run was declined, if it was, for the user to read. */ + public Optional reason() { + return Optional.ofNullable(reason); + } + + /** + * Gets which preprocessor declined the run, if one did. + *

+ * NB: the instance rather than its class, since a chain may hold several + * instances of one class and the useful question is which of them objected. + *

+ */ + public Optional declinedBy() { + return Optional.ofNullable(declinedBy); + } + + /** Gets the outputs, by name. Empty if the run was declined. */ + public Map outputs() { + return outputs; + } + + @Override + public String toString() { + return isDeclined() ? "declined: " + reason : "completed: " + outputs; + } +} diff --git a/execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/execute/src/main/java/org/scijava/execute/FieldParameterMember.java new file mode 100644 index 000000000..f463b5db5 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -0,0 +1,203 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.invoke.VarHandle; +import java.lang.reflect.Field; +import java.lang.reflect.Type; + +import org.scijava.common3.Types; +import org.scijava.struct.ItemIO; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.ValueAccessible; +import org.scijava.struct.ValueAccessibleMemberInstance; + +/** + * A {@link Member} backed by a {@link Parameter}-annotated field. + * + * @author Curtis Rueden + */ +public class FieldParameterMember implements ParameterMember, + ValueAccessible +{ + + private final Field field; + private final Parameter parameter; + private final Type itemType; + private final Lookup lookup; + private VarHandle handle; + + public FieldParameterMember(final Field field, final Type structType) { + this(field, structType, null); + } + + /** + * Creates a member whose value is read and written through the given lookup. + *

+ * NB: reflective access is checked against the module that performs it, so + * a lookup supplied by the container is what lets a plugin author open + * their package to the container alone, rather than to every SciJava module + * that might touch their fields. + *

+ * + * @param field the annotated field + * @param structType the type declaring it + * @param lookup a lookup with private access to the declaring class, or + * null to reflect with this module's own access + */ + public FieldParameterMember(final Field field, final Type structType, + final Lookup lookup) + { + this.lookup = lookup; + this.field = field; + this.parameter = field.getAnnotation(Parameter.class); + if (parameter == null) { + throw new IllegalArgumentException("Not a @Parameter field: " + field); + } + // NB: resolve against the declaring type, so that a generic field of a + // parameterized class reports the type it actually has here. + this.itemType = Types.typeOf(field, Types.raw(structType)); + } + + @Override + public String key() { + return field.getName(); + } + + @Override + public String description() { + return parameter.description(); + } + + @Override + public Type type() { + return itemType; + } + + @Override + public ItemIO getIOType() { + return parameter.io(); + } + + @Override + public boolean isRequired() { + return parameter.required(); + } + + @Override + public MemberInstance createInstance(final Object o) { + // NB: the default instance is read-only; this member can write, so it + // must say so, or binding an input silently fails. + return new ValueAccessibleMemberInstance<>(this, o); + } + + @Override + public Map attrs() { + final Map attrs = new LinkedHashMap<>(); + put(attrs, LABEL, parameter.label()); + put(attrs, CALLBACK, parameter.callback()); + put(attrs, VALIDATOR, parameter.validator()); + put(attrs, VISIBLE_WHEN, parameter.visibleWhen()); + put(attrs, GROUP, parameter.group()); + put(attrs, CHOICES, String.join(",", parameter.choices())); + put(attrs, CHOICES_FROM, parameter.choicesFrom()); + put(attrs, MIN, parameter.min()); + put(attrs, MAX, parameter.max()); + put(attrs, STEP_SIZE, parameter.stepSize()); + put(attrs, SOFT_MIN, parameter.softMin()); + put(attrs, SOFT_MAX, parameter.softMax()); + put(attrs, STYLE, parameter.style()); + if (!parameter.persist()) attrs.put(PERSIST, "false"); + put(attrs, PERSIST_KEY, parameter.persistKey()); + return attrs; + } + + private static void put(final Map attrs, final String key, + final String value) + { + if (!value.isEmpty()) attrs.put(key, value); + } + + @Override + @SuppressWarnings("unchecked") + public T get(final Object o) { + return (T) handle().get(o); + } + + @Override + public void set(final T value, final Object o) { + handle().set(o, value); + } + + /** Gets the handle for this field, resolving it on first use. */ + private synchronized VarHandle handle() { + if (handle == null) { + try { + final Lookup access = lookup != null ? lookup // + : ownLookupIn(field.getDeclaringClass()); + handle = access.unreflectVarHandle(field); + } + catch (final RuntimeException | IllegalAccessException exc) { + throw new IllegalStateException(cannotAccess(), exc); + } + } + return handle; + } + + /** + * Gets a lookup into the given class using this module's own access. + *

+ * NB: {@code privateLookupIn} requires this module to read the + * target's module as well as the package being open to it. A library never + * declares a read edge to its callers, so it has to add one at runtime - + * which only its own code may do. Callers with a container avoid all this + * by passing a lookup from there instead. + *

+ */ + private static Lookup ownLookupIn(final Class type) + throws IllegalAccessException + { + FieldParameterMember.class.getModule().addReads(type.getModule()); + return MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } + + private String cannotAccess() { + return "Cannot access " + field.getDeclaringClass().getName() + "." + // + field.getName() + ". Does its module declare `opens " + field + .getDeclaringClass().getPackageName() + " to org.scijava.context;`?" + // + " A caller with no container must open to org.scijava.execute instead."; + } +} diff --git a/execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java b/execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java new file mode 100644 index 000000000..483cf175b --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java @@ -0,0 +1,78 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import org.scijava.struct.Member; +import org.scijava.struct.MemberParser; + +/** + * Builds {@link Member}s from the {@link Parameter}-annotated fields of a + * class, including those it inherits. + * + * @author Curtis Rueden + */ +public class FieldParameterMemberParser implements + MemberParser, Member> +{ + + private final Lookup lookup; + + public FieldParameterMemberParser() { + this(null); + } + + /** + * @param lookup a lookup with private access to the classes being parsed, + * or null to reflect with this module's own access + */ + public FieldParameterMemberParser(final Lookup lookup) { + this.lookup = lookup; + } + + @Override + public List> parse(final Class source, final Type structType) { + if (source == null) return List.of(); + final List> members = new ArrayList<>(); + // NB: walk up the hierarchy, so an abstract base class may declare + // parameters shared by its subclasses. + for (Class c = source; c != null; c = c.getSuperclass()) { + for (final var field : c.getDeclaredFields()) { + if (!field.isAnnotationPresent(Parameter.class)) continue; + members.add(new FieldParameterMember<>(field, structType, lookup)); + } + } + return members; + } +} diff --git a/execute/src/main/java/org/scijava/execute/JavaExecutable.java b/execute/src/main/java/org/scijava/execute/JavaExecutable.java new file mode 100644 index 000000000..5758e837d --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/JavaExecutable.java @@ -0,0 +1,190 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Method; +import java.util.Optional; + +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * An {@link Executable} backed by a Java class whose {@link Parameter} fields + * declare its parameters, and which implements {@link Runnable}. + * + * @author Curtis Rueden + */ +public class JavaExecutable implements Executable { + + private final Class type; + private final Struct struct; + private final Lookup lookup; + + public JavaExecutable(final Class type) { + this(type, null); + } + + /** + * @param type the class to describe + * @param lookup a lookup with private access to it, or null to reflect with + * this module's own access. A container supplies one so that the + * class's package need only be opened to the container. + */ + public JavaExecutable(final Class type, + final Lookup lookup) + { + this.type = type; + this.lookup = lookup; + this.struct = Executables.struct(type, lookup); + } + + @Override + public String name() { + return type.getName(); + } + + @Override + public Struct struct() { + return struct; + } + + @Override + public ExecutableInstance create() { + final Runnable object = instantiate(); + final StructInstance parameters = struct.createInstance(object); + return new ExecutableInstance() { + + @Override + public Executable executable() { + return JavaExecutable.this; + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + object.run(); + } + + @Override + public Optional behavior(final String name) { + return methodNamed(name).map(method -> args -> invoke(method, object, + args)); + } + }; + } + + /** Gets the class this describes. */ + public Class type() { + return type; + } + + /** + * Resolves a named behavior on the given object. + *

+ * NB: shared with {@link Executables#executableOf}, so that an object handed + * in directly supports callbacks and generators exactly as a discovered + * class does. Having only one of the two paths resolve behaviors made them + * silently do nothing, which is a poor way to find out. + *

+ */ + static Optional behaviorOf(final Class type, final Lookup lookup, + final Object target, final String name) + { + return methodNamed(type, name).map(method -> args -> invoke(type, lookup, + method, target, args)); + } + + /** Finds a declared method by name, in this class or a superclass. */ + private Optional methodNamed(final String name) { + return methodNamed(type, name); + } + + private static Optional methodNamed(final Class type, + final String name) + { + for (Class c = type; c != null; c = c.getSuperclass()) { + for (final Method method : c.getDeclaredMethods()) { + // NB: by name alone. A behavior is identified by its name, and + // overloading one would be ambiguous rather than useful. + if (method.getName().equals(name)) return Optional.of(method); + } + } + return Optional.empty(); + } + + private Object invoke(final Method method, final Object target, + final Object[] args) + { + return invoke(type, lookup, method, target, args); + } + + private static Object invoke(final Class type, final Lookup lookup, + final Method method, final Object target, final Object[] args) + { + try { + final Lookup access; + if (lookup != null) access = lookup; + else { + // NB: see FieldParameterMember.ownLookupIn -- privateLookupIn needs + // this module to read the target's, which only its own code may add. + JavaExecutable.class.getModule().addReads(type.getModule()); + access = MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } + final MethodHandle handle = access.unreflect(method); + final Object[] all = new Object[args.length + 1]; + all[0] = target; + System.arraycopy(args, 0, all, 1, args.length); + return handle.invokeWithArguments(all); + } + catch (final Throwable exc) { + throw new BehaviorException("Behavior failed: " + type.getName() + "." + // + method.getName(), exc); + } + } + + private Runnable instantiate() { + try { + return type.getDeclaredConstructor().newInstance(); + } + catch (final ReflectiveOperationException exc) { + throw new IllegalStateException("Cannot construct " + type.getName() + // + ". Does it have a no-argument constructor, and does its module" + // + " declare `opens " + type.getPackageName() + // + " to org.scijava.execute;`?", exc); + } + } +} diff --git a/execute/src/main/java/org/scijava/execute/Parameter.java b/execute/src/main/java/org/scijava/execute/Parameter.java new file mode 100644 index 000000000..eed71a9c8 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Parameter.java @@ -0,0 +1,180 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.scijava.struct.ItemIO; + +/** + * Declares a field as an input or output of the thing that declares it. + * + *
+ * public class AddNumbers implements Runnable {
+ *
+ * 	@Parameter
+ * 	private double a;
+ *
+ * 	@Parameter
+ * 	private double b;
+ *
+ * 	@Parameter(io = ItemIO.OUTPUT)
+ * 	private double result;
+ *
+ * 	public void run() { result = a + b; }
+ * }
+ * 
+ *

+ * NB: this is the SciJava Common meaning of {@code @Parameter} - an input to + * something runnable - and only that meaning. Injecting a service is + * {@code org.scijava.context.Dependency}, a separate annotation, because + * conflating the two is what made the original confusing. + *

+ * + * @author Curtis Rueden + * @see Executables#struct(Class) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Parameter { + + /** Whether this parameter is an input, an output, or both. */ + ItemIO io() default ItemIO.INPUT; + + /** Whether a value must be supplied. */ + boolean required() default true; + + /** + * Whether to remember this value between runs. + *

+ * A dialog opens showing what the user chose last time, which is what makes + * one feel like it remembers them. Say no for a value that should not carry + * over - a file to overwrite, a one-off seed, anything whose staleness + * would be a nasty surprise. + *

+ */ + boolean persist() default true; + + /** + * The name this value is remembered under, where its own would be wrong. + *

+ * Renaming a field would otherwise lose what the user chose; naming it here + * keeps the two apart. + *

+ */ + String persistKey() default ""; + + /** A human-readable label, for display. */ + String label() default ""; + + /** A longer human-readable description. */ + String description() default ""; + + /** + * A behavior to run when this parameter's value changes. + *

+ * NB: a name, not a method reference, so that a script can supply one too; + * see {@link Behavior}. + *

+ */ + String callback() default ""; + + /** + * A behavior that checks this parameter's value, returning a message + * describing the problem, or null or an empty string if there is none. + */ + String validator() default ""; + + /** A behavior deciding whether this parameter is currently shown. */ + String visibleWhen() default ""; + + /** The group this parameter belongs to, if any. */ + String group() default ""; + + /** + * The values this parameter may take, where they are known in advance. + * + * @see #choicesFrom() + */ + String[] choices() default {}; + + /** + * A behavior producing the values this parameter may take, where they + * depend on runtime state - the axes of the open image, the fields of the + * chosen table. + *

+ * NB: this is what SciJava Common needed {@code DynamicCommand} for. There, + * a command mutated its own {@code ModuleInfo} to call {@code setChoices}, + * which made the description itself mutable and per-instance: a platform + * could not know a command's parameters without constructing it. Declaring + * that the choices come from a behavior keeps the parameter statically + * visible while its values stay dynamic. + *

+ */ + String choicesFrom() default ""; + + /** The smallest value this parameter may take, for a numeric parameter. */ + String min() default ""; + + /** The largest value this parameter may take, for a numeric parameter. */ + String max() default ""; + + /** How far one step moves a numeric parameter, in a spinner or slider. */ + String stepSize() default ""; + + /** + * The smallest value a slider should span, where the useful range is + * narrower than the permitted one. + *

+ * A slider between 0 and {@link Integer#MAX_VALUE} is unusable, so a + * parameter may say that its interesting values stop at 100 while still + * accepting a larger one typed into the spinner. + *

+ */ + String softMin() default ""; + + /** The largest value a slider should span. @see #softMin() */ + String softMax() default ""; + + /** + * A hint about how to display this parameter, for a user interface to + * interpret: {@code "slider"}, {@code "password"}, {@code "directory"}. + *

+ * Several hints are separated by commas, and one may carry a value after a + * colon: {@code "slider,format:0.00"}. A user interface ignores hints it + * does not recognize, so a parameter styled for one toolkit still renders + * in another. + *

+ */ + String style() default ""; +} diff --git a/execute/src/main/java/org/scijava/execute/ParameterMember.java b/execute/src/main/java/org/scijava/execute/ParameterMember.java new file mode 100644 index 000000000..15aeef7fb --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/ParameterMember.java @@ -0,0 +1,138 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.util.Map; +import java.util.Optional; + +import org.scijava.struct.Member; + +/** + * A parameter that carries metadata beyond its name and type: a label, a + * callback, a validator, which group it belongs to, how it should be + * displayed. + *

+ * The metadata is a string map rather than a set of typed accessors, and + * deliberately so: it has to cross language boundaries. A Java command + * declares it in annotations, a script declares it in its header, and neither + * can see the other's types. Strings are what a script header actually holds, + * so pretending otherwise would only move the parsing somewhere less obvious. + * Typed accessors wrap the well-known keys. + *

+ * + * @author Curtis Rueden + */ +public interface ParameterMember extends Member { + + /** Metadata key: the behavior run when this parameter changes. */ + String CALLBACK = "callback"; + + /** Metadata key: the behavior that checks this parameter's value. */ + String VALIDATOR = "validator"; + + /** Metadata key: the behavior deciding whether this parameter is shown. */ + String VISIBLE_WHEN = "visibleWhen"; + + /** Metadata key: the group this parameter belongs to. */ + String GROUP = "group"; + + /** Metadata key: a human-readable label. */ + String LABEL = "label"; + + /** Metadata key: the values this parameter may take, comma-separated. */ + String CHOICES = "choices"; + + /** Metadata key: the behavior producing the values it may take. */ + String CHOICES_FROM = "choicesFrom"; + + /** Metadata key: the smallest value a numeric parameter may take. */ + String MIN = "min"; + + /** Metadata key: the largest value a numeric parameter may take. */ + String MAX = "max"; + + /** Metadata key: how far one step moves a numeric parameter. */ + String STEP_SIZE = "stepSize"; + + /** Metadata key: the smallest value a slider should span. */ + String SOFT_MIN = "softMin"; + + /** Metadata key: the largest value a slider should span. */ + String SOFT_MAX = "softMax"; + + /** Metadata key: whether to remember this value between runs. */ + String PERSIST = "persist"; + + /** Metadata key: the name to remember this value under. */ + String PERSIST_KEY = "persistKey"; + + /** Metadata key: a hint about how to display this parameter. */ + String STYLE = "style"; + + /** Gets this parameter's metadata. */ + Map attrs(); + + /** Gets one metadata value, absent if unset or empty. */ + default Optional attr(final String key) { + final String value = attrs().get(key); + return value == null || value.isEmpty() ? Optional.empty() // + : Optional.of(value); + } + + /** + * Gets the label to display, falling back to the parameter's name split + * into words: {@code numDims} becomes "Num dims". + *

+ * NB: SciJava Common merely capitalised the first letter, so a camel-case + * name arrived in the dialog as "NumDims". Splitting reads better and costs + * nothing, since an explicit label overrides it anyway. + *

+ */ + default String label() { + return attr(LABEL).orElseGet(() -> humanize(key())); + } + + /** Turns a camel-case identifier into a readable label. */ + static String humanize(final String key) { + if (key == null || key.isEmpty()) return key; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < key.length(); i++) { + final char c = key.charAt(i); + if (i == 0) sb.append(Character.toUpperCase(c)); + else if (Character.isUpperCase(c) && !Character.isUpperCase(key.charAt(i - + 1))) + { + sb.append(' ').append(Character.toLowerCase(c)); + } + else sb.append(c); + } + return sb.toString(); + } +} diff --git a/execute/src/main/java/org/scijava/execute/Parameters.java b/execute/src/main/java/org/scijava/execute/Parameters.java new file mode 100644 index 000000000..00eb8bd4a --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Parameters.java @@ -0,0 +1,190 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.scijava.struct.ItemIO; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * Parameters declared at runtime, with their values in a map. + *

+ * Two quite different needs turn out to want the same thing. A script declares + * its parameters in a header and its engine holds the values in bindings - + * there is no Java field anywhere. And a group whose shape depends on + * a value ("three dimension labels, or seven, according to the count above") + * cannot be fields either, since a class's fields are fixed when it is + * compiled. + *

+ *

+ * So both are served by parameters built to order over a map. That the two + * arrived at one primitive is the main thing prototyping the dialog use cases + * taught us. + *

+ * + *
+ * Parameters.builder() //
+ * 	.add("dim0", String.class) //
+ * 	.add("dim1", String.class) //
+ * 	.build();
+ * 
+ * + * @author Curtis Rueden + */ +public final class Parameters { + + private Parameters() { + // NB: prevent instantiation of utility class. + } + + /** Starts building a set of parameters. */ + public static Builder builder() { + return new Builder(); + } + + /** Collects parameter declarations, then binds them to a map of values. */ + public static class Builder { + + private final List> members = new ArrayList<>(); + private final Map values = new LinkedHashMap<>(); + + /** Adds an input parameter. */ + public Builder add(final String key, final Type type) { + return add(key, type, ItemIO.INPUT, Map.of(), null); + } + + /** Adds an input parameter with an initial value. */ + public Builder add(final String key, final Type type, + final Object initialValue) + { + return add(key, type, ItemIO.INPUT, Map.of(), initialValue); + } + + /** Adds a parameter. */ + public Builder add(final String key, final Type type, final ItemIO io, + final Map attrs, final Object initialValue) + { + members.add(new MapMember<>(key, type, io, attrs)); + if (initialValue != null) values.put(key, initialValue); + return this; + } + + /** Gets the parameters, bound to a fresh map of values. */ + public StructInstance> build() { + final Struct struct = () -> members; + return struct.createInstance(values); + } + + /** Gets the map the built parameters read and write. */ + public Map values() { + return values; + } + } + + /** A parameter whose value lives in a map rather than a field. */ + private static class MapMember implements ParameterMember { + + private final String key; + private final Type type; + private final ItemIO io; + private final Map attrs; + + MapMember(final String key, final Type type, final ItemIO io, + final Map attrs) + { + this.key = key; + this.type = type; + this.io = io; + this.attrs = attrs; + } + + @Override + public String key() { + return key; + } + + @Override + public Type type() { + return type; + } + + @Override + public ItemIO getIOType() { + return io; + } + + @Override + public Map attrs() { + return attrs; + } + + @Override + public MemberInstance createInstance(final Object o) { + @SuppressWarnings("unchecked") + final Map values = (Map) o; + return new MemberInstance<>() { + + @Override + public Member member() { + return MapMember.this; + } + + @Override + public boolean isReadable() { + return true; + } + + @Override + public boolean isWritable() { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public T get() { + return (T) values.get(key); + } + + @Override + public void set(final Object value) { + values.put(key, value); + } + }; + } + } +} diff --git a/execute/src/main/java/org/scijava/execute/Postprocessor.java b/execute/src/main/java/org/scijava/execute/Postprocessor.java new file mode 100644 index 000000000..7a7ab6b3f --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Postprocessor.java @@ -0,0 +1,53 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import org.scijava.priority.Priority; + +/** + * Handles the outputs of a run that has finished - displaying them, recording + * them, converting them. + *

+ * A postprocessor cannot decline: the work has already happened and its + * outputs exist, so declining would be a lie. To report a problem, throw. + *

+ * + * @author Curtis Rueden + */ +public interface Postprocessor { + + /** Handles the outputs of the given run. */ + void process(Execution execution); + + /** Sorts postprocessors, highest first. See {@link Priority}. */ + default double priority() { + return Priority.NORMAL; + } +} diff --git a/execute/src/main/java/org/scijava/execute/Preprocessor.java b/execute/src/main/java/org/scijava/execute/Preprocessor.java new file mode 100644 index 000000000..153f4c313 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Preprocessor.java @@ -0,0 +1,65 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import org.scijava.priority.Priority; + +/** + * Prepares a run before it happens: filling in inputs, checking preconditions, + * harvesting values from the user. + *

+ * Contribute one as a plugin: + *

+ * + *
+ * @Plugin(type = Preprocessor.class)
+ * public class MyPreprocessor implements Preprocessor { ... }
+ * 
+ *

+ * A preprocessor may stop the run by calling + * {@link Execution#decline(String)}; later preprocessors are then skipped. + *

+ *

+ * Implementations should be stateless: the container constructs one per run, + * and a caller may supply a chain containing several instances of one class. + *

+ * + * @author Curtis Rueden + */ +public interface Preprocessor { + + /** Prepares the given run. */ + void process(Execution execution); + + /** Sorts preprocessors, highest first. See {@link Priority}. */ + default double priority() { + return Priority.NORMAL; + } +} diff --git a/execute/src/main/java/org/scijava/execute/Runner.java b/execute/src/main/java/org/scijava/execute/Runner.java new file mode 100644 index 000000000..8dba94131 --- /dev/null +++ b/execute/src/main/java/org/scijava/execute/Runner.java @@ -0,0 +1,258 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; + +import org.scijava.struct.MemberInstance; +import org.scijava.struct.StructInstance; + +/** + * Runs objects through a chain of {@link Preprocessor}s and + * {@link Postprocessor}s. + *

+ * {@link #run} is always asynchronous, returning a + * {@code Future}: the future carries the asynchrony and + * cancellation, and the result carries what happened. A caller wanting to + * block writes {@code run(...).get()} - one verb covers both. + *

+ *

+ * Cancellation is thread interruption throughout: cancelling the future + * interrupts the run, which stops between processors and inside any work that + * checks for it. + *

+ * + * @author Curtis Rueden + */ +public class Runner { + + private final ExecutorService executor; + private final List preprocessors; + private final List postprocessors; + + /** + * Creates a runner with the given processor chain. + * + * @param executor where runs happen; the caller owns its lifecycle + * @param preprocessors preparation steps, applied highest priority first + * @param postprocessors output handling, applied highest priority first + */ + public Runner(final ExecutorService executor, + final List preprocessors, + final List postprocessors) + { + this.executor = executor; + this.preprocessors = sorted(preprocessors, Preprocessor::priority); + this.postprocessors = sorted(postprocessors, Postprocessor::priority); + } + + /** + * Creates a runner with its own executor, whose threads are daemons so + * that they never hold the JVM open. + */ + public static Runner of(final List preprocessors, + final List postprocessors) + { + return new Runner(daemonExecutor(), preprocessors, postprocessors); + } + + /** + * Runs the given executable: preprocess, run, postprocess. + * + * @param executable what to run + * @param inputs values for its input parameters, by name + * @return the outcome - outputs, or why the run was declined + */ + public Future run(final Executable executable, + final Map inputs) + { + return executor.submit(() -> execute(executable, inputs)); + } + + /** + * Runs a Java class whose {@link Parameter} fields declare its parameters. + * Sugar for the common case; the object itself is used, rather than a fresh + * instance. + * + * @param executable the object to run + * @param inputs values for its input parameters, by name + * @return the outcome - outputs, or why the run was declined + */ + public Future run(final Runnable executable, + final Map inputs) + { + return run(Executables.executableOf(executable), inputs); + } + + /** Gets the preprocessors, highest priority first. */ + public List preprocessors() { + return List.copyOf(preprocessors); + } + + /** Gets the postprocessors, highest priority first. */ + public List postprocessors() { + return List.copyOf(postprocessors); + } + + // -- Helper methods -- + + private ExecutionResult execute(final Executable executable, + final Map inputs) + { + final ExecutableInstance instance = executable.create(); + final DefaultExecution execution = new DefaultExecution(instance, // + Executables.bind(instance.parameters(), inputs, false), inputs.keySet()); + + for (final Preprocessor preprocessor : preprocessors) { + Executables.checkCancellation(); + preprocessor.process(execution); + if (execution.isDeclined()) { + // NB: later preprocessors are skipped -- the run is off. + return ExecutionResult.declined(execution.reason().orElse(null), + preprocessor); + } + } + + // NB: preprocessors may have supplied missing inputs, so requirements + // are checked here rather than at binding time. + checkRequiredInputs(execution.instance()); + + Executables.checkCancellation(); + instance.run(); + Executables.checkCancellation(); + + execution.complete(Executables.outputs(execution.instance())); + for (final Postprocessor postprocessor : postprocessors) { + postprocessor.process(execution); + } + return ExecutionResult.completed(execution.outputs()); + } + + private static void checkRequiredInputs(final StructInstance instance) { + for (final MemberInstance member : instance.members()) { + if (!member.member().isInput()) continue; + if (!member.member().isRequired()) continue; + if (member.get() == null) { + throw new IllegalArgumentException("Missing required parameter: " + // + member.member().key() + ". No preprocessor supplied it."); + } + } + } + + private static

List

sorted(final List

processors, + final java.util.function.ToDoubleFunction

priority) + { + final List

list = new ArrayList<>(processors); + list.sort(Comparator.comparingDouble(priority).reversed()); + return list; + } + + private static ExecutorService daemonExecutor() { + final AtomicLong counter = new AtomicLong(); + final ThreadFactory factory = r -> { + final Thread thread = new Thread(r, "scijava-execute-" + counter + .incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + return Executors.newCachedThreadPool(factory); + } + + /** The execution handed to each processor. */ + private static class DefaultExecution implements Execution { + + private final ExecutableInstance instance; + private final StructInstance parameters; + private String reason; + private boolean declined; + private Map outputs = Map.of(); + + /** The names the caller passed, as opposed to what a field holds. */ + private final Set supplied; + + DefaultExecution(final ExecutableInstance instance, + final StructInstance parameters, final Set supplied) + { + this.instance = instance; + this.parameters = parameters; + this.supplied = Set.copyOf(supplied); + } + + @Override + public boolean isSupplied(final String key) { + return supplied.contains(key); + } + + void complete(final Map outputs) { + this.outputs = outputs; + } + + @Override + public ExecutableInstance executable() { + return instance; + } + + @Override + public StructInstance instance() { + return parameters; + } + + @Override + public void decline(final String reason) { + this.declined = true; + this.reason = reason; + } + + @Override + public boolean isDeclined() { + return declined; + } + + @Override + public Optional reason() { + return Optional.ofNullable(reason); + } + + @Override + public Map outputs() { + return outputs; + } + } +} diff --git a/execute/src/test/java/org/scijava/execute/ExecutablesTest.java b/execute/src/test/java/org/scijava/execute/ExecutablesTest.java new file mode 100644 index 000000000..32d5626cc --- /dev/null +++ b/execute/src/test/java/org/scijava/execute/ExecutablesTest.java @@ -0,0 +1,252 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.struct.ItemIO; +import org.scijava.struct.Member; + +/** + * Tests {@link Executables}. + * + * @author Curtis Rueden + */ +public class ExecutablesTest { + + public static class AddNumbers implements Runnable { + + @Parameter + private double a; + + @Parameter + private double b; + + @Parameter(io = ItemIO.OUTPUT) + private double result; + + @Override + public void run() { + result = a + b; + } + } + + @Test + public void testRun() { + final Map outputs = Executables.run(new AddNumbers(), + Map.of("a", 2.0, "b", 3.0)); + assertEquals(Map.of("result", 5.0), outputs); + } + + @Test + public void testStructDescribesTheParameters() { + final List inputs = describe(AddNumbers.class, true); + final List outputs = describe(AddNumbers.class, false); + assertEquals(List.of("a", "b"), inputs); + assertEquals(List.of("result"), outputs); + } + + @Test + public void testUnknownParameterIsRejected() { + final IllegalArgumentException exc = assertThrows( + IllegalArgumentException.class, () -> Executables.run(new AddNumbers(), + Map.of("a", 1.0, "b", 2.0, "nope", 3.0))); + // The message should say what is actually available. + assertTrue(exc.getMessage().contains("nope"), exc.getMessage()); + assertTrue(exc.getMessage().contains("a, b, result"), exc.getMessage()); + } + + public static class NeedsName implements Runnable { + + @Parameter + private String name; + + @Parameter(required = false) + private String nickname; + + @Parameter(io = ItemIO.OUTPUT) + private String greeting; + + @Override + public void run() { + greeting = "hello " + (nickname == null ? name : nickname); + } + } + + @Test + public void testMissingRequiredParameter() { + final IllegalArgumentException exc = assertThrows( + IllegalArgumentException.class, () -> Executables.run(new NeedsName(), + Map.of())); + assertTrue(exc.getMessage().contains("name"), exc.getMessage()); + } + + @Test + public void testOptionalParameterMayBeOmitted() { + assertEquals(Map.of("greeting", "hello ada"), Executables.run( + new NeedsName(), Map.of("name", "ada"))); + assertEquals(Map.of("greeting", "hello duchess"), Executables.run( + new NeedsName(), Map.of("name", "ada", "nickname", "duchess"))); + } + + /** Parameters declared by a base class are inherited. */ + public static abstract class HasBase implements Runnable { + + @Parameter + protected int base; + } + + public static class Doubler extends HasBase { + + @Parameter(io = ItemIO.OUTPUT) + private int doubled; + + @Override + public void run() { + doubled = base * 2; + } + } + + @Test + public void testInheritedParameters() { + assertEquals(Map.of("doubled", 8), Executables.run(new Doubler(), Map.of( + "base", 4))); + } + + // -- Cancellation -- + + public static class SlowTask implements Runnable { + + final CountDownLatch started = new CountDownLatch(1); + + @Parameter + private int millis; + + @Parameter(io = ItemIO.OUTPUT) + private String status; + + @Override + public void run() { + started.countDown(); + try { + Thread.sleep(millis); + status = "finished"; + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + } + } + + /** Cancelling the future stops the run, per the cancellation design. */ + @Test + public void testCancellation() throws Exception { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final SlowTask task = new SlowTask(); + final Future> future = executor.submit(Executables + .callable(task, Map.of("millis", 30_000))); + assertTrue(task.started.await(5, TimeUnit.SECONDS)); + + assertTrue(future.cancel(true)); + assertThrows(CancellationException.class, () -> future.get(5, + TimeUnit.SECONDS)); + assertFalse("finished".equals(statusOf(task)), "the task ran to the end"); + } + finally { + executor.shutdownNow(); + } + } + + private static String statusOf(final SlowTask task) { + return (String) Executables.struct(SlowTask.class).createInstance(task) + .member("status").get(); + } + + private static List describe(final Class type, + final boolean inputs) + { + return Executables.struct(type).members().stream() // + .filter(m -> inputs ? m.isInput() : m.isOutput()) // + .map(Member::key) // + .collect(Collectors.toList()); + } + + // -- Conversion of supplied inputs -- + + /** A caller with only strings - a script, a command line - is served. */ + @Test + public void testInputsAreConverted() { + final Map outputs = Executables.run(new Adder(), Map.of( // + "a", "1.5", "b", 2)); + + assertEquals(3.5, outputs.get("result")); + } + + /** What cannot become the parameter's type says so, naming the parameter. */ + @Test + public void testUnconvertibleInput() { + final IllegalArgumentException exc = assertThrows( + IllegalArgumentException.class, () -> Executables.run(new Adder(), Map.of( // + "a", new Object(), "b", 2))); + assertTrue(exc.getMessage().contains("'a'"), exc.getMessage()); + } + + public static class Adder implements Runnable { + + @Parameter + private double a; + + @Parameter + private double b; + + @Parameter(io = ItemIO.OUTPUT) + private double result; + + @Override + public void run() { + result = a + b; + } + } +} diff --git a/execute/src/test/java/org/scijava/execute/RunnerTest.java b/execute/src/test/java/org/scijava/execute/RunnerTest.java new file mode 100644 index 000000000..2cd0670d9 --- /dev/null +++ b/execute/src/test/java/org/scijava/execute/RunnerTest.java @@ -0,0 +1,249 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.scijava.priority.Priority; +import org.scijava.struct.ItemIO; + +/** + * Tests {@link Runner}: the processor chain, declining, and cancellation. + * + * @author Curtis Rueden + */ +public class RunnerTest { + + public static class Greet implements Runnable { + + @Parameter + private String name; + + @Parameter(io = ItemIO.OUTPUT) + private String greeting; + + @Override + public void run() { + greeting = "hello " + name; + } + } + + @Test + public void testRunWithNoProcessors() throws Exception { + final Runner runner = Runner.of(List.of(), List.of()); + final ExecutionResult result = runner.run(new Greet(), Map.of("name", + "ada")).get(5, TimeUnit.SECONDS); + assertTrue(result.isCompleted()); + assertEquals(Map.of("greeting", "hello ada"), result.outputs()); + } + + /** A preprocessor may supply an input the caller omitted. */ + @Test + public void testPreprocessorSuppliesInput() throws Exception { + final Preprocessor filler = execution -> execution.instance().member("name") + .set("grace"); + final Runner runner = Runner.of(List.of(filler), List.of()); + final ExecutionResult result = runner.run(new Greet(), Map.of()).get(5, + TimeUnit.SECONDS); + assertEquals(Map.of("greeting", "hello grace"), result.outputs()); + } + + /** Declining is an ordinary outcome, carrying its reason and its author. */ + @Test + public void testDecline() throws Exception { + final Preprocessor refuser = execution -> execution.decline( + "No image is open"); + final Runner runner = Runner.of(List.of(refuser), List.of()); + + final ExecutionResult result = runner.run(new Greet(), Map.of("name", + "ada")).get(5, TimeUnit.SECONDS); + + assertTrue(result.isDeclined()); + assertFalse(result.isCompleted()); + assertEquals("No image is open", result.reason().orElse(null)); + assertSame(refuser, result.declinedBy().orElse(null)); + assertTrue(result.outputs().isEmpty()); + } + + /** + * Two instances of one class in a chain: the result must name the one that + * actually declined, which a Class could not do. + */ + @Test + public void testDeclinedByDistinguishesInstances() throws Exception { + class Gate implements Preprocessor { + + private final boolean refuse; + + Gate(final boolean refuse) { + this.refuse = refuse; + } + + @Override + public void process(final Execution execution) { + if (refuse) execution.decline("refused"); + } + } + final Gate permissive = new Gate(false); + final Gate strict = new Gate(true); + final Runner runner = Runner.of(List.of(permissive, strict), List.of()); + + final ExecutionResult result = runner.run(new Greet(), Map.of("name", + "ada")).get(5, TimeUnit.SECONDS); + assertSame(strict, result.declinedBy().orElse(null)); + } + + /** Once declined, later preprocessors do not run, nor does the object. */ + @Test + public void testDeclineStopsTheChain() throws Exception { + final List calls = new ArrayList<>(); + final Preprocessor first = execution -> { + calls.add("first"); + execution.decline("stop"); + }; + final Preprocessor second = execution -> calls.add("second"); + final Runner runner = Runner.of(List.of(first, second), List.of()); + + runner.run(new Greet(), Map.of("name", "ada")).get(5, TimeUnit.SECONDS); + assertEquals(List.of("first"), calls); + } + + @Test + public void testProcessorPriorityOrder() throws Exception { + final List order = new ArrayList<>(); + final Preprocessor low = new Preprocessor() { + + @Override + public void process(final Execution execution) { + order.add("low"); + } + + @Override + public double priority() { + return Priority.LOW; + } + }; + final Preprocessor high = new Preprocessor() { + + @Override + public void process(final Execution execution) { + order.add("high"); + } + + @Override + public double priority() { + return Priority.HIGH; + } + }; + // NB: supplied in the "wrong" order, so sorting is what decides. + final Runner runner = Runner.of(List.of(low, high), List.of()); + runner.run(new Greet(), Map.of("name", "ada")).get(5, TimeUnit.SECONDS); + assertEquals(List.of("high", "low"), order); + } + + /** A postprocessor sees the outputs. */ + @Test + public void testPostprocessorSeesOutputs() throws Exception { + final List seen = new ArrayList<>(); + final Postprocessor recorder = execution -> seen.add(execution.outputs() + .get("greeting")); + final Runner runner = Runner.of(List.of(), List.of(recorder)); + + runner.run(new Greet(), Map.of("name", "ada")).get(5, TimeUnit.SECONDS); + assertEquals(List.of("hello ada"), seen); + } + + /** A required input nobody supplied is an error, not a silent null. */ + @Test + public void testUnsatisfiedRequiredInput() { + final Runner runner = Runner.of(List.of(), List.of()); + final ExecutionException exc = assertThrows(ExecutionException.class, + () -> runner.run(new Greet(), Map.of()).get(5, TimeUnit.SECONDS)); + assertTrue(exc.getCause() instanceof IllegalArgumentException); + assertTrue(exc.getCause().getMessage().contains("name"), exc.getCause() + .getMessage()); + } + + // -- Cancellation -- + + public static class SlowGreet implements Runnable { + + static final CountDownLatch STARTED = new CountDownLatch(1); + + @Parameter + private String name; + + @Parameter(io = ItemIO.OUTPUT) + private String greeting; + + @Override + public void run() { + STARTED.countDown(); + try { + Thread.sleep(30_000); + greeting = "hello " + name; + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Cancelling the future interrupts the run. NB: this is a different thing + * from declining - work was already under way. + */ + @Test + public void testCancellationIsNotDeclining() throws Exception { + final Runner runner = Runner.of(List.of(), List.of()); + final Future future = runner.run(new SlowGreet(), Map.of( + "name", "ada")); + assertTrue(SlowGreet.STARTED.await(5, TimeUnit.SECONDS)); + + assertTrue(future.cancel(true)); + // Cancellation surfaces as an exception, where declining is a result. + assertThrows(CancellationException.class, () -> future.get(5, + TimeUnit.SECONDS)); + } +} diff --git a/execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java b/execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java new file mode 100644 index 000000000..72cd05c13 --- /dev/null +++ b/execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java @@ -0,0 +1,211 @@ +/* + * #%L + * Running things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.execute; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.scijava.struct.ItemIO; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * Proves that {@link Executable} admits things that are not Java classes with + * annotated fields - a script being the case that matters. + *

+ * The executable here declares its parameters from a map, as a script declares + * them in its header, and stores their values in a map, as a script engine + * holds bindings. No {@code @Parameter} field appears anywhere, and yet it runs + * through the same {@link Runner}, with the same preprocessors and the same + * result. + *

+ * + * @author Curtis Rueden + */ +public class ScriptLikeExecutableTest { + + /** A parameter declared by name and type, as a script header does. */ + private static class ScriptMember implements Member { + + private final String key; + private final Type type; + private final ItemIO io; + + ScriptMember(final String key, final Type type, final ItemIO io) { + this.key = key; + this.type = type; + this.io = io; + } + + @Override + public String key() { + return key; + } + + @Override + public Type type() { + return type; + } + + @Override + public ItemIO getIOType() { + return io; + } + + @Override + public MemberInstance createInstance(final Object o) { + @SuppressWarnings("unchecked") + final Map bindings = (Map) o; + return new MemberInstance<>() { + + @Override + public Member member() { + return ScriptMember.this; + } + + @Override + public boolean isReadable() { + return true; + } + + @Override + public boolean isWritable() { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public T get() { + return (T) bindings.get(key); + } + + @Override + public void set(final Object value) { + bindings.put(key, value); + } + }; + } + } + + /** Stands in for a script: parameters from a header, values in bindings. */ + private static class FakeScript implements Executable { + + private final List> members = List.of( // + new ScriptMember("name", String.class, ItemIO.INPUT), // + new ScriptMember("shout", String.class, ItemIO.OUTPUT)); + + @Override + public String name() { + return "greet.groovy"; + } + + @Override + public Struct struct() { + return () -> members; + } + + @Override + public ExecutableInstance create() { + final Map bindings = new HashMap<>(); + final StructInstance parameters = struct().createInstance(bindings); + return new ExecutableInstance() { + + @Override + public Executable executable() { + return FakeScript.this; + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + // As a script engine would: read bindings, write bindings. + bindings.put("shout", ("hello " + bindings.get("name")) + .toUpperCase()); + } + }; + } + } + + @Test + public void testAScriptShapedExecutableRuns() throws Exception { + final Runner runner = Runner.of(List.of(), List.of()); + final ExecutionResult result = runner.run(new FakeScript(), Map.of("name", + "ada")).get(5, TimeUnit.SECONDS); + + assertTrue(result.isCompleted()); + assertEquals(Map.of("shout", "HELLO ADA"), result.outputs()); + } + + /** It reaches the same preprocessors, and can be declined the same way. */ + @Test + public void testProcessorsApplyToScriptsToo() throws Exception { + final List seen = new ArrayList<>(); + final Preprocessor filler = execution -> { + seen.add(execution.executable().executable() + .name()); + execution.instance().member("name").set("grace"); + }; + final Runner runner = Runner.of(List.of(filler), List.of()); + + final ExecutionResult result = runner.run(new FakeScript(), Map.of()).get(5, + TimeUnit.SECONDS); + assertEquals(List.of("greet.groovy"), seen); + assertEquals(Map.of("shout", "HELLO GRACE"), result.outputs()); + } + + /** Each run gets its own bindings, so concurrent runs cannot collide. */ + @Test + public void testEachRunHasItsOwnState() throws Exception { + final FakeScript script = new FakeScript(); + final Runner runner = Runner.of(List.of(), List.of()); + + final ExecutionResult first = runner.run(script, Map.of("name", "ada")).get( + 5, TimeUnit.SECONDS); + final ExecutionResult second = runner.run(script, Map.of("name", "grace")) + .get(5, TimeUnit.SECONDS); + + assertEquals(Map.of("shout", "HELLO ADA"), first.outputs()); + assertEquals(Map.of("shout", "HELLO GRACE"), second.outputs()); + } +} diff --git a/scijava-function/LICENSE.txt b/function/LICENSE.txt similarity index 100% rename from scijava-function/LICENSE.txt rename to function/LICENSE.txt diff --git a/scijava-function/README.md b/function/README.md similarity index 100% rename from scijava-function/README.md rename to function/README.md diff --git a/scijava-function/bin/generate.groovy b/function/bin/generate.groovy similarity index 100% rename from scijava-function/bin/generate.groovy rename to function/bin/generate.groovy diff --git a/scijava-function/pom.xml b/function/pom.xml similarity index 100% rename from scijava-function/pom.xml rename to function/pom.xml diff --git a/scijava-function/src/main/java/module-info.java b/function/src/main/java/module-info.java similarity index 100% rename from scijava-function/src/main/java/module-info.java rename to function/src/main/java/module-info.java diff --git a/scijava-function/src/main/java/org/scijava/function/Computers.java b/function/src/main/java/org/scijava/function/Computers.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Computers.java rename to function/src/main/java/org/scijava/function/Computers.java diff --git a/scijava-function/src/main/java/org/scijava/function/Consumers.java b/function/src/main/java/org/scijava/function/Consumers.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Consumers.java rename to function/src/main/java/org/scijava/function/Consumers.java diff --git a/scijava-function/src/main/java/org/scijava/function/Container.java b/function/src/main/java/org/scijava/function/Container.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Container.java rename to function/src/main/java/org/scijava/function/Container.java diff --git a/scijava-function/src/main/java/org/scijava/function/Functions.java b/function/src/main/java/org/scijava/function/Functions.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Functions.java rename to function/src/main/java/org/scijava/function/Functions.java diff --git a/scijava-function/src/main/java/org/scijava/function/Inplaces.java b/function/src/main/java/org/scijava/function/Inplaces.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Inplaces.java rename to function/src/main/java/org/scijava/function/Inplaces.java diff --git a/scijava-function/src/main/java/org/scijava/function/Mutable.java b/function/src/main/java/org/scijava/function/Mutable.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Mutable.java rename to function/src/main/java/org/scijava/function/Mutable.java diff --git a/scijava-function/src/main/java/org/scijava/function/Producer.java b/function/src/main/java/org/scijava/function/Producer.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/Producer.java rename to function/src/main/java/org/scijava/function/Producer.java diff --git a/scijava-function/src/main/java/org/scijava/function/package-info.java b/function/src/main/java/org/scijava/function/package-info.java similarity index 100% rename from scijava-function/src/main/java/org/scijava/function/package-info.java rename to function/src/main/java/org/scijava/function/package-info.java diff --git a/scijava-function/templates/main/java/org/scijava/function/Computers.list b/function/templates/main/java/org/scijava/function/Computers.list similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Computers.list rename to function/templates/main/java/org/scijava/function/Computers.list diff --git a/scijava-function/templates/main/java/org/scijava/function/Computers.vm b/function/templates/main/java/org/scijava/function/Computers.vm similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Computers.vm rename to function/templates/main/java/org/scijava/function/Computers.vm diff --git a/scijava-function/templates/main/java/org/scijava/function/Consumers.list b/function/templates/main/java/org/scijava/function/Consumers.list similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Consumers.list rename to function/templates/main/java/org/scijava/function/Consumers.list diff --git a/scijava-function/templates/main/java/org/scijava/function/Consumers.vm b/function/templates/main/java/org/scijava/function/Consumers.vm similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Consumers.vm rename to function/templates/main/java/org/scijava/function/Consumers.vm diff --git a/scijava-function/templates/main/java/org/scijava/function/Functions.list b/function/templates/main/java/org/scijava/function/Functions.list similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Functions.list rename to function/templates/main/java/org/scijava/function/Functions.list diff --git a/scijava-function/templates/main/java/org/scijava/function/Functions.vm b/function/templates/main/java/org/scijava/function/Functions.vm similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Functions.vm rename to function/templates/main/java/org/scijava/function/Functions.vm diff --git a/scijava-function/templates/main/java/org/scijava/function/Globals.list b/function/templates/main/java/org/scijava/function/Globals.list similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Globals.list rename to function/templates/main/java/org/scijava/function/Globals.list diff --git a/scijava-function/templates/main/java/org/scijava/function/Inplaces.list b/function/templates/main/java/org/scijava/function/Inplaces.list similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Inplaces.list rename to function/templates/main/java/org/scijava/function/Inplaces.list diff --git a/scijava-function/templates/main/java/org/scijava/function/Inplaces.vm b/function/templates/main/java/org/scijava/function/Inplaces.vm similarity index 100% rename from scijava-function/templates/main/java/org/scijava/function/Inplaces.vm rename to function/templates/main/java/org/scijava/function/Inplaces.vm diff --git a/harvest/pom.xml b/harvest/pom.xml new file mode 100644 index 000000000..09dffed5b --- /dev/null +++ b/harvest/pom.xml @@ -0,0 +1,149 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-harvest + 1.0.0-SNAPSHOT + + SciJava Harvest + The model behind a parameter dialog: groups, dependencies, validation. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.harvest + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/harvest/src/main/java/module-info.java b/harvest/src/main/java/module-info.java new file mode 100644 index 000000000..dd2c558d1 --- /dev/null +++ b/harvest/src/main/java/module-info.java @@ -0,0 +1,38 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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% + */ + +open module org.scijava.harvest { + + exports org.scijava.harvest; + + requires transitive org.scijava.execute; + requires transitive org.scijava.struct; + requires org.scijava.common3; + +} diff --git a/harvest/src/main/java/org/scijava/harvest/Group.java b/harvest/src/main/java/org/scijava/harvest/Group.java new file mode 100644 index 000000000..c683dd67d --- /dev/null +++ b/harvest/src/main/java/org/scijava/harvest/Group.java @@ -0,0 +1,79 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Describes a group of parameters: a box in a dialog, possibly collapsible, + * possibly conditional, possibly generated. + * + *
+ * @Group(name = "Advanced", collapsible = true, collapsed = true)
+ * @Group(name = "Dimensions", membersFrom = "dimensionMembers", after = "numDims")
+ * public class MyCommand implements Command { ... }
+ * 
+ * + * @author Curtis Rueden + */ +@Repeatable(Groups.class) +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Group { + + /** The group's name, as parameters refer to it. */ + String name(); + + /** A human-readable label, defaulting to the name. */ + String label() default ""; + + /** Whether the user can fold this group away. */ + boolean collapsible() default false; + + /** Whether it starts folded. */ + boolean collapsed() default false; + + /** A behavior deciding whether this group is currently shown. */ + String visibleWhen() default ""; + + /** + * A behavior producing this group's parameters, for a group whose very + * shape depends on other values - three dimension labels or seven, + * according to a count entered above. + */ + String membersFrom() default ""; + + /** Which parameter this group follows, for a generated group. */ + String after() default ""; +} diff --git a/harvest/src/main/java/org/scijava/harvest/Groups.java b/harvest/src/main/java/org/scijava/harvest/Groups.java new file mode 100644 index 000000000..2659319a2 --- /dev/null +++ b/harvest/src/main/java/org/scijava/harvest/Groups.java @@ -0,0 +1,43 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Holds repeated {@link Group} declarations. */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Groups { + + Group[] value(); +} diff --git a/harvest/src/main/java/org/scijava/harvest/ObservableStruct.java b/harvest/src/main/java/org/scijava/harvest/ObservableStruct.java new file mode 100644 index 000000000..3a2ac3863 --- /dev/null +++ b/harvest/src/main/java/org/scijava/harvest/ObservableStruct.java @@ -0,0 +1,184 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.scijava.struct.MemberInstance; +import org.scijava.struct.StructInstance; + +/** + * A {@link StructInstance} that reports when its values change. + *

+ * A dialog needs this for a reason that only becomes obvious once callbacks + * exist: a callback may set any parameter, not merely the one that + * triggered it, so after running one the only way to know what moved is to + * re-read everything and compare. {@link #refresh()} does that and reports + * every difference. At dialog sizes the cost is irrelevant, and there is no + * cleverer answer - SciJava Common dirty-checks for the same reason. + *

+ *

+ * This is a decorator rather than a change to {@link MemberInstance}: values + * are only observable where somebody is watching, which is to say in a user + * interface. + *

+ * + * @author Curtis Rueden + */ +public class ObservableStruct { + + /** What happened to one parameter. */ + public static class Change { + + private final MemberInstance member; + private final Object oldValue; + private final Object newValue; + + public Change(final MemberInstance member, final Object oldValue, + final Object newValue) + { + this.member = member; + this.oldValue = oldValue; + this.newValue = newValue; + } + + public MemberInstance member() { + return member; + } + + public String key() { + return member.member().key(); + } + + public Object oldValue() { + return oldValue; + } + + public Object newValue() { + return newValue; + } + + @Override + public String toString() { + return key() + ": " + oldValue + " -> " + newValue; + } + } + + private final StructInstance instance; + private final Map lastSeen = new LinkedHashMap<>(); + private final List> listeners = new ArrayList<>(); + + public ObservableStruct(final StructInstance instance) { + this.instance = instance; + snapshot(); + } + + /** Gets the underlying parameters. */ + public StructInstance instance() { + return instance; + } + + /** Registers a listener, notified once per changed parameter. */ + public void onChange(final Consumer listener) { + listeners.add(listener); + } + + /** + * Sets a parameter and reports what changed, including anything a callback + * altered along the way. + * + * @param key the parameter to set + * @param value its new value + * @return every parameter that differs from before + */ + public List set(final String key, final Object value) { + final MemberInstance member = instance.member(key); + if (member == null) { + throw new IllegalArgumentException("No such parameter: " + key); + } + member.set(value); + return refresh(); + } + + /** + * Re-reads every parameter, reporting those that differ from the last time + * anyone looked. + *

+ * Call this after anything that might have changed values behind the + * dialog's back - a callback, an initializer, the command mutating itself. + *

+ * + * @return every parameter that changed + */ + public List refresh() { + final List changes = new ArrayList<>(); + for (final MemberInstance member : instance.members()) { + final String key = member.member().key(); + final Object now = value(member); + final Object before = lastSeen.get(key); + if (!equal(before, now)) { + lastSeen.put(key, now); + changes.add(new Change(member, before, now)); + } + } + // NB: notify only after the whole scan, so that a listener sees a + // settled state rather than one being read out from under it. + for (final Change change : changes) { + for (final Consumer listener : listeners) + listener.accept(change); + } + return changes; + } + + /** Gets a parameter's current value. */ + public Object get(final String key) { + final MemberInstance member = instance.member(key); + return member == null ? null : value(member); + } + + // -- Helper methods -- + + private void snapshot() { + for (final MemberInstance member : instance.members()) + lastSeen.put(member.member().key(), value(member)); + } + + private static Object value(final MemberInstance member) { + return member.isReadable() ? member.get() : null; + } + + private static boolean equal(final Object a, final Object b) { + return a == null ? b == null : a.equals(b); + } +} diff --git a/harvest/src/main/java/org/scijava/harvest/ParameterModel.java b/harvest/src/main/java/org/scijava/harvest/ParameterModel.java new file mode 100644 index 000000000..11d9276e1 --- /dev/null +++ b/harvest/src/main/java/org/scijava/harvest/ParameterModel.java @@ -0,0 +1,207 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import org.scijava.execute.Behavior; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.ParameterMember; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; + +/** + * What a parameter dialog talks to: current values, the shape they imply, the + * problems with them. + *

+ * Setting a value here does everything that follows from it - runs the + * parameter's callback, notices whatever else the callback changed, and + * rebuilds the tree, since the shape of a dialog depends on its values. A user + * interface sets values and re-renders; it needs to know nothing about + * callbacks, groups or generated parameters. + *

+ * + * @author Curtis Rueden + */ +public class ParameterModel { + + private final ExecutableInstance instance; + private final ObservableStruct values; + private final List> listeners = new ArrayList<>(); + private ParameterTree tree; + + public ParameterModel(final ExecutableInstance instance) { + this.instance = instance; + this.values = new ObservableStruct(instance.parameters()); + this.tree = ParameterTree.of(instance.parameters(), instance); + } + + /** Gets the shape the dialog should currently have. */ + public ParameterTree tree() { + return tree; + } + + /** Gets the current values. */ + public ObservableStruct values() { + return values; + } + + /** Gets a parameter's value. */ + public Object get(final String key) { + return values.get(key); + } + + /** Registers a listener, notified whenever the dialog's shape changes. */ + public void onTreeChanged(final Consumer listener) { + listeners.add(listener); + } + + /** + * Sets a parameter, then does everything that follows: runs its callback, + * notices what else changed, and rebuilds the tree. + * + * @param key the parameter to set + * @param value its new value + * @return every parameter that changed, the callback's doing included + */ + public List set(final String key, + final Object value) + { + final List changes = new ArrayList<>(values.set(key, + value)); + + // NB: a callback may change other parameters, whose own callbacks then + // have to run - but a pair that set each other would never stop, so each + // parameter's callback runs at most once per set(). + final Set called = new HashSet<>(); + List pending = new ArrayList<>(changes); + pending.add(0, null); // NB: placeholder for the parameter set directly + pending.set(0, changeFor(key)); + while (!pending.isEmpty()) { + final List next = new ArrayList<>(); + for (final ObservableStruct.Change change : pending) { + if (change == null) continue; + if (!called.add(change.key())) continue; + runCallback(change.key()).ifPresent(more -> { + next.addAll(more); + changes.addAll(more); + }); + } + pending = next; + } + + rebuild(); + return changes; + } + + /** + * Gets what is wrong with the current values, by parameter name. + *

+ * A validator reports a problem by returning a message; anything else means + * the value is fine. NB: SciJava Common let a validator either throw or + * return a message, so every caller had to handle both. One way is enough, + * and a thrown exception is then a bug in the validator rather than a second + * protocol. + *

+ * + * @return the problems, in parameter order; empty if the values are usable + */ + public Map problems() { + final Map problems = new LinkedHashMap<>(); + for (final MemberInstance member : tree.members()) { + final String key = member.member().key(); + validator(member).ifPresent(behavior -> { + final Object result = behavior.invoke(member.get()); + if (result instanceof String && !((String) result).isEmpty()) { + problems.put(key, (String) result); + } + }); + } + return problems; + } + + /** Gets whether the current values are usable. */ + public boolean isValid() { + return problems().isEmpty(); + } + + // -- Helper methods -- + + private void rebuild() { + final ParameterTree rebuilt = ParameterTree.of(instance.parameters(), + instance); + final boolean changed = !rebuilt.toString().equals(tree.toString()); + tree = rebuilt; + if (changed) { + for (final Consumer listener : listeners) + listener.accept(tree); + } + } + + private ObservableStruct.Change changeFor(final String key) { + final MemberInstance member = instance.parameters().member(key); + return member == null ? null // + : new ObservableStruct.Change(member, null, values.get(key)); + } + + /** Runs a parameter's callback, if it has one, reporting what moved. */ + private Optional> runCallback( + final String key) + { + final MemberInstance member = instance.parameters().member(key); + if (member == null) return Optional.empty(); + final Optional callback = behavior(member, + ParameterMember.CALLBACK); + if (callback.isEmpty()) return Optional.empty(); + callback.get().invoke(); + // NB: the callback may have set anything at all, so the only way to know + // is to look at everything. + return Optional.of(values.refresh()); + } + + private Optional validator(final MemberInstance member) { + return behavior(member, ParameterMember.VALIDATOR); + } + + private Optional behavior(final MemberInstance member, + final String attr) + { + final Member m = member.member(); + if (!(m instanceof ParameterMember)) return Optional.empty(); + return ((ParameterMember) m).attr(attr).flatMap(instance::behavior); + } +} diff --git a/harvest/src/main/java/org/scijava/harvest/ParameterNode.java b/harvest/src/main/java/org/scijava/harvest/ParameterNode.java new file mode 100644 index 000000000..231c350e0 --- /dev/null +++ b/harvest/src/main/java/org/scijava/harvest/ParameterNode.java @@ -0,0 +1,79 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import java.util.List; +import java.util.Optional; + +import org.scijava.struct.MemberInstance; + +/** + * A node in the tree a dialog renders: either one parameter, or a group of + * them. + * + * @author Curtis Rueden + */ +public interface ParameterNode { + + /** Gets the label to display. */ + String label(); + + /** Gets the parameter this node edits, if it is a single parameter. */ + Optional> member(); + + /** Gets the nodes beneath this one, empty for a single parameter. */ + List children(); + + /** Gets whether this node holds others. */ + default boolean isGroup() { + return member().isEmpty(); + } + + /** Gets whether the user can fold this group away. */ + default boolean isCollapsible() { + return false; + } + + /** Gets whether this group starts folded. */ + default boolean isCollapsed() { + return false; + } + + /** + * Gets the values this parameter may take, as resolved right now. + *

+ * A user interface renders a chooser when this is non-empty and does not + * care whether the values were declared or computed. + *

+ */ + default List choices() { + return List.of(); + } +} diff --git a/harvest/src/main/java/org/scijava/harvest/ParameterTree.java b/harvest/src/main/java/org/scijava/harvest/ParameterTree.java new file mode 100644 index 000000000..5cbda6e49 --- /dev/null +++ b/harvest/src/main/java/org/scijava/harvest/ParameterTree.java @@ -0,0 +1,413 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.scijava.execute.Behavior; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.Executables; +import org.scijava.execute.ParameterMember; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * The shape of a parameter dialog at one moment: which parameters are shown, + * how they are grouped, and in what order. + *

+ * It is rebuilt whenever a value changes, because the shape itself depends on + * the values: a chosen implementation brings its own parameters, an "advanced" + * toggle reveals a group, a count decides how many labels appear. A user + * interface renders a tree, and re-renders when handed a new one. + *

+ * + * @author Curtis Rueden + */ +public class ParameterTree { + + private final List nodes; + + private ParameterTree(final List nodes) { + this.nodes = nodes; + } + + /** Gets the top-level nodes, in display order. */ + public List nodes() { + return nodes; + } + + /** Gets every parameter in the tree, groups flattened, in display order. */ + public List> members() { + final List> members = new ArrayList<>(); + collect(nodes, members); + return members; + } + + /** Finds a node by its parameter's name. */ + public Optional find(final String key) { + return find(nodes, key); + } + + /** + * Builds the tree for the given parameters. + * + * @param instance the parameter values, which the shape may depend on + * @param executable the thing being run, which supplies named behaviors + * @return the tree as it should appear right now + */ + public static ParameterTree of(final StructInstance instance, + final ExecutableInstance executable) + { + return new Builder(instance, executable).build(); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + print(nodes, sb, 0); + return sb.toString(); + } + + // -- Helper methods -- + + private static void collect(final List nodes, + final List> members) + { + for (final ParameterNode node : nodes) { + node.member().ifPresent(members::add); + collect(node.children(), members); + } + } + + private static Optional find(final List nodes, + final String key) + { + for (final ParameterNode node : nodes) { + final Optional> member = node.member(); + if (member.isPresent() && member.get().member().key().equals(key)) { + return Optional.of(node); + } + final Optional found = find(node.children(), key); + if (found.isPresent()) return found; + } + return Optional.empty(); + } + + private static void print(final List nodes, + final StringBuilder sb, final int depth) + { + for (final ParameterNode node : nodes) { + sb.append(" ".repeat(depth)).append(node.isGroup() ? "[" + node.label() + + "]" : node.label()).append('\n'); + print(node.children(), sb, depth + 1); + } + } + + /** Builds a tree from parameters, their metadata, and current values. */ + private static class Builder { + + private final StructInstance instance; + private final ExecutableInstance executable; + private final Map groups = new LinkedHashMap<>(); + + Builder(final StructInstance instance, + final ExecutableInstance executable) + { + this.instance = instance; + this.executable = executable; + for (final Group group : declaredGroups()) + groups.put(group.name(), group); + } + + ParameterTree build() { + final List nodes = new ArrayList<>(); + final Map groupNodes = new LinkedHashMap<>(); + + for (final MemberInstance member : instance.members()) { + if (!member.member().isInput()) continue; + if (!isVisible(member)) continue; + + final ParameterNode node = nodeFor(member); + final Optional group = attr(member, ParameterMember.GROUP); + if (group.isEmpty()) { + nodes.add(node); + } + else { + // NB: a group appears where its first member would have. + final DefaultNode groupNode = groupNodes.computeIfAbsent(group + .get(), name -> { + final DefaultNode created = groupNode(name); + nodes.add(created); + return created; + }); + groupNode.children.add(node); + } + appendGeneratedGroups(nodes, member.member().key()); + } + // NB: a group whose visibility says no disappears entirely, rather + // than showing as an empty box. + nodes.removeIf(node -> node.isGroup() && node.children().isEmpty()); + return new ParameterTree(nodes); + } + + /** Builds the node for one parameter, expanding it if it is a struct. */ + private ParameterNode nodeFor(final MemberInstance member) { + final DefaultNode node = new DefaultNode(label(member), member); + node.choices = choices(member); + for (final MemberInstance child : subMembers(member)) + node.children.add(nodeFor(child)); + return node; + } + + /** + * Gets the values a parameter may take, declared or computed. + *

+ * NB: computing them is what SciJava Common used {@code DynamicCommand} + * for, by mutating the command's own description. Here the parameter + * stays statically visible and only its values are dynamic. + *

+ */ + private List choices(final MemberInstance member) { + final Optional from = attr(member, + ParameterMember.CHOICES_FROM); + if (from.isPresent()) { + final Optional behavior = executable.behavior(from.get()); + if (behavior.isPresent()) return asList(behavior.get().invoke()); + } + final Optional declared = attr(member, ParameterMember.CHOICES); + if (declared.isPresent()) { + return new ArrayList<>(List.of(declared.get().split(","))); + } + // NB: an enum declares its own values, so asking for them again in an + // annotation would only be a chance to get them wrong. + final Class type = org.scijava.common3.Types.raw(member.member() + .type()); + if (type != null && type.isEnum()) { + return new ArrayList<>(List.of(type.getEnumConstants())); + } + return List.of(); + } + + private static List asList(final Object result) { + if (result == null) return List.of(); + if (result instanceof List) { + return new ArrayList<>((List) result); + } + if (result instanceof Object[]) { + return new ArrayList<>(List.of((Object[]) result)); + } + if (result instanceof Iterable) { + final List list = new ArrayList<>(); + ((Iterable) result).forEach(list::add); + return list; + } + return List.of(result); + } + + /** + * Gets the parameters inside a parameter, if it has any. + *

+ * NB: driven by the value's class rather than the declared type, which is + * what makes a chosen implementation work: the field says + * {@code Joke}, the value is a knock-knock joke, and it is the joke's own + * parameters that should appear. Expanding the declared type would find + * nothing, since an interface declares no fields. + *

+ */ + private List> subMembers( + final MemberInstance member) + { + final Object value = member.isReadable() ? member.get() : null; + if (value == null) { + // NB: with no value there is nothing to read or write, but the + // group's shape is still known from the declared type, so a dialog + // can show it rather than an unexplained gap. Only a concrete type + // has fields; an unset `Joke` shows nothing until one is chosen, + // which is correct. + return List.of(); + } + final Struct struct = Executables.struct(value.getClass()); + final List> members = struct.members(); + if (members.isEmpty()) return List.of(); + return struct.createInstance(value).members(); + } + + /** Adds any generated groups anchored after the given parameter. */ + private void appendGeneratedGroups(final List nodes, + final String afterKey) + { + for (final Group group : groups.values()) { + if (group.membersFrom().isEmpty()) continue; + if (!afterKey.equals(group.after())) continue; + if (!isVisible(group)) continue; + final DefaultNode node = groupNode(group.name()); + for (final MemberInstance member : generatedMembers(group)) + node.children.add(nodeFor(member)); + if (!node.children.isEmpty()) nodes.add(node); + } + } + + /** Asks the executable for a generated group's parameters. */ + private List> generatedMembers(final Group group) { + final Optional behavior = executable.behavior(group + .membersFrom()); + if (behavior.isEmpty()) return List.of(); + final Object result = behavior.get().invoke(); + if (result instanceof StructInstance) { + return ((StructInstance) result).members(); + } + if (result instanceof Struct) { + // NB: a struct with no object behind it cannot be read or written, + // so a generator must hand back an instance. + throw new IllegalStateException("Generator " + group.membersFrom() + // + " returned a Struct; it must return a StructInstance, bound to" + // + " something that holds the values"); + } + if (result == null) return List.of(); + // A plain object: its own parameters are the group's. + return Executables.struct(result.getClass()).createInstance(result) + .members(); + } + + private DefaultNode groupNode(final String name) { + final Group group = groups.get(name); + final String label = group == null || group.label().isEmpty() ? name // + : group.label(); + final DefaultNode node = new DefaultNode(label, null); + if (group != null) { + node.collapsible = group.collapsible(); + node.collapsed = group.collapsed(); + } + return node; + } + + private boolean isVisible(final MemberInstance member) { + final Optional when = attr(member, + ParameterMember.VISIBLE_WHEN); + if (when.isEmpty()) { + // A parameter in a group inherits that group's visibility. + final Optional group = attr(member, ParameterMember.GROUP); + return group.isEmpty() || isVisible(groups.get(group.get())); + } + return ask(when.get()); + } + + private boolean isVisible(final Group group) { + return group == null || group.visibleWhen().isEmpty() || ask(group + .visibleWhen()); + } + + private boolean ask(final String behaviorName) { + final Optional behavior = executable.behavior(behaviorName); + if (behavior.isEmpty()) return true; + return !Boolean.FALSE.equals(behavior.get().invoke()); + } + + private static Optional attr(final MemberInstance member, + final String key) + { + final Member m = member.member(); + return m instanceof ParameterMember ? ((ParameterMember) m).attr(key) + : Optional.empty(); + } + + private static String label(final MemberInstance member) { + final Member m = member.member(); + return m instanceof ParameterMember ? ((ParameterMember) m).label() + : m.key(); + } + + private List declaredGroups() { + final Object object = executable.parameters().object(); + if (object == null) return List.of(); + final Group[] declared = object.getClass().getAnnotationsByType( + Group.class); + return declared == null ? List.of() : List.of(declared); + } + } + + /** A node in the tree. */ + private static class DefaultNode implements ParameterNode { + + private final String label; + private final MemberInstance member; + private final List children = new ArrayList<>(); + private boolean collapsible; + private boolean collapsed; + private List choices = List.of(); + + DefaultNode(final String label, final MemberInstance member) { + this.label = label; + this.member = member; + } + + @Override + public String label() { + return label; + } + + @Override + public Optional> member() { + return Optional.ofNullable(member); + } + + @Override + public List children() { + return Collections.unmodifiableList(children); + } + + @Override + public boolean isCollapsible() { + return collapsible; + } + + @Override + public boolean isCollapsed() { + return collapsed; + } + + @Override + public List choices() { + return choices; + } + + @Override + public String toString() { + return isGroup() ? "[" + label + "]" : label; + } + } +} diff --git a/harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java b/harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java new file mode 100644 index 000000000..34f8f5a41 --- /dev/null +++ b/harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java @@ -0,0 +1,346 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.scijava.execute.Behavior; +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.Executables; +import org.scijava.execute.Parameter; +import org.scijava.execute.ParameterMember; +import org.scijava.execute.Parameters; +import org.scijava.struct.ItemIO; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * Tests callbacks and validation - including from a script, which SciJava + * Common never managed. + * + * @author Curtis Rueden + */ +public class ParameterModelTest { + + // -- Callbacks, in Java -- + + public static class Rectangle implements Runnable { + + @Parameter(callback = "widthChanged") + private int width = 4; + + @Parameter + private int height = 3; + + @Parameter(io = ItemIO.OUTPUT) + private int area = 12; + + @SuppressWarnings("unused") + private void widthChanged() { + // NB: a callback may change anything, not only its own parameter. + area = width * height; + } + + @Override + public void run() { + area = width * height; + } + } + + @Test + public void testCallbackChangesAnotherParameter() { + final ParameterModel model = model(new Rectangle()); + + final List changes = model.set("width", 10); + + assertEquals(30, model.get("area")); + // The change to area is reported, not merely applied. + assertTrue(changes.stream().anyMatch(c -> "area".equals(c.key())), + changes.toString()); + } + + // -- A callback pair that would otherwise never stop -- + + public static class Celsius implements Runnable { + + @Parameter(callback = "celsiusChanged") + private double celsius = 0; + + @Parameter(callback = "fahrenheitChanged") + private double fahrenheit = 32; + + @SuppressWarnings("unused") + private void celsiusChanged() { + fahrenheit = celsius * 9 / 5 + 32; + } + + @SuppressWarnings("unused") + private void fahrenheitChanged() { + celsius = (fahrenheit - 32) * 5 / 9; + } + + @Override + public void run() {} + } + + /** Two parameters that set each other must settle rather than loop. */ + @Test + public void testMutualCallbacksTerminate() { + final ParameterModel model = model(new Celsius()); + model.set("celsius", 100.0); + assertEquals(212.0, model.get("fahrenheit")); + } + + // -- Validation -- + + public static class Bounded implements Runnable { + + @Parameter(validator = "checkCount") + private int count = 5; + + @SuppressWarnings("unused") + private String checkCount(final Object value) { + final int n = (Integer) value; + return n > 0 && n <= 10 ? null : "Count must be between 1 and 10"; + } + + @Override + public void run() {} + } + + @Test + public void testValidation() { + final ParameterModel model = model(new Bounded()); + assertTrue(model.isValid()); + assertTrue(model.problems().isEmpty()); + + model.set("count", 42); + assertFalse(model.isValid()); + assertEquals(Map.of("count", "Count must be between 1 and 10"), model + .problems()); + + model.set("count", 7); + assertTrue(model.isValid()); + } + + // -- The same, from a script -- + + /** + * Stands in for a script: parameters declared in a header, values in engine + * bindings, and behaviors that are functions in the script rather than Java + * methods. + *

+ * This is what SciJava Common could not express. Its callbacks were method + * names resolved by Java reflection, so a script had nothing to name; here + * a name is resolved by whatever kind of code the executable is. + *

+ */ + private static class FakeScript implements Executable { + + private final StructInstance> parameters; + private final Map functions = new HashMap<>(); + + FakeScript() { + // #@ String name (callback = "nameChanged") + // #@ boolean shout + // #@output String greeting + parameters = Parameters.builder() // + .add("name", String.class, ItemIO.INPUT, // + Map.of(ParameterMember.CALLBACK, "nameChanged"), "ada") // + .add("shout", Boolean.class, ItemIO.INPUT, Map.of(), false) // + .add("greeting", String.class, ItemIO.OUTPUT, Map.of(), "hello ada") // + .build(); + + // def nameChanged() { greeting = ... } + functions.put("nameChanged", args -> { + final Map values = parameters.object(); + final String greeting = "hello " + values.get("name"); + values.put("greeting", Boolean.TRUE.equals(values.get("shout")) // + ? greeting.toUpperCase() : greeting); + return null; + }); + // def checkName(value) { ... } + functions.put("checkName", args -> { + final String value = String.valueOf(args[0]); + return value.isEmpty() ? "A name is required" : null; + }); + } + + @Override + public String name() { + return "greet.groovy"; + } + + @Override + public Struct struct() { + return parameters.struct(); + } + + @Override + public ExecutableInstance create() { + return new ExecutableInstance() { + + @Override + public Executable executable() { + return FakeScript.this; + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + functions.get("nameChanged").invoke(); + } + + @Override + public Optional behavior(final String name) { + return Optional.ofNullable(functions.get(name)); + } + }; + } + } + + /** A script's callback runs when its parameter changes. */ + @Test + public void testScriptCallback() { + final ParameterModel model = new ParameterModel(new FakeScript().create()); + assertEquals("hello ada", model.get("greeting")); + + model.set("name", "grace"); + assertEquals("hello grace", model.get("greeting")); + + // A second parameter the callback reads, proving it sees whole state. + model.set("shout", true); + model.set("name", "ada"); + assertEquals("HELLO ADA", model.get("greeting")); + } + + /** A script's validator reports problems the same way a Java one does. */ + @Test + public void testScriptValidator() { + final FakeScript script = new FakeScript(); + final StructInstance> parameters = Parameters + .builder() // + .add("name", String.class, ItemIO.INPUT, // + Map.of(ParameterMember.VALIDATOR, "checkName"), "ada") // + .build(); + final ExecutableInstance base = script.create(); + final ParameterModel model = new ParameterModel(new ExecutableInstance() { + + @Override + public Executable executable() { + return base.executable(); + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() {} + + @Override + public Optional behavior(final String name) { + return base.behavior(name); + } + }); + + assertTrue(model.isValid()); + model.set("name", ""); + assertEquals(Map.of("name", "A name is required"), model.problems()); + } + + // -- Choices, static and computed -- + + public static class PickAxis implements Runnable { + + /** Which axes exist depends on the image, so the values are computed. */ + @Parameter(choicesFrom = "axisChoices") + private String axis = "X"; + + @Parameter(choices = { "Mean", "Median", "Max" }) + private String statistic = "Mean"; + + /** Stands in for "the axes of the currently open image". */ + private List axes = List.of("X", "Y", "Z"); + + @SuppressWarnings("unused") + private List axisChoices() { + return axes; + } + + @Override + public void run() {} + } + + /** + * Computed choices are what SciJava Common needed DynamicCommand for: a + * command mutating its own description to call setChoices. Here the + * parameter stays statically visible and only its values are dynamic. + */ + @Test + public void testComputedChoices() { + final PickAxis command = new PickAxis(); + final ParameterModel model = model(command); + + assertEquals(List.of("X", "Y", "Z"), model.tree().find("axis") + .orElseThrow().choices()); + + // A different image: different axes, same declared parameter. + command.axes = List.of("Channel", "Time"); + model.set("axis", "X"); + assertEquals(List.of("Channel", "Time"), model.tree().find("axis") + .orElseThrow().choices()); + } + + @Test + public void testDeclaredChoices() { + final ParameterModel model = model(new PickAxis()); + assertEquals(List.of("Mean", "Median", "Max"), model.tree().find( + "statistic").orElseThrow().choices()); + } + + private static ParameterModel model(final Runnable object) { + final Executable executable = Executables.executableOf(object); + return new ParameterModel(executable.create()); + } +} diff --git a/harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java b/harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java new file mode 100644 index 000000000..4841051e0 --- /dev/null +++ b/harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java @@ -0,0 +1,269 @@ +/* + * #%L + * The model behind a parameter dialog: groups, dependencies, validation. + * %% + * Copyright (C) 2026 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.harvest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.Executables; +import org.scijava.execute.Parameter; +import org.scijava.execute.Parameters; +import org.scijava.struct.StructInstance; + +/** + * Exercises the parameter tree against the dialog behaviours actually asked + * for: a chosen implementation bringing its own parameters, a static nested + * group, an "advanced" toggle, and a group whose size depends on a value. + * + * @author Curtis Rueden + */ +public class ParameterTreeTest { + + // -- 1. Chosen implementation: the value decides the parameters -- + + public interface Joke {} + + public static class KnockKnock implements Joke { + + @Parameter(label = "Who's there?") + private String whosThere = "Boo"; + + @Parameter + private String punchline = "Boo hoo"; + } + + public static class OneLiner implements Joke { + + @Parameter + private String setup = "I told my wife she was drawing her eyebrows too high"; + } + + public static class TellJoke implements Runnable { + + @Parameter + private String teller = "Chuckles"; + + @Parameter(label = "Favorite joke") + private Joke joke = new KnockKnock(); + + @Override + public void run() {} + } + + @Test + public void testChosenImplementationBringsItsOwnParameters() { + final TellJoke command = new TellJoke(); + final ExecutableInstance instance = instanceOf(command); + final ParameterTree tree = ParameterTree.of(instance.parameters(), + instance); + + final ParameterNode joke = tree.find("joke").orElseThrow(); + assertEquals(List.of("Who's there?", "Punchline"), labels(joke.children())); + + // Choose a different joke: the sub-parameters change with it. + final ObservableStruct observable = new ObservableStruct(instance + .parameters()); + observable.set("joke", new OneLiner()); + final ParameterTree rebuilt = ParameterTree.of(instance.parameters(), + instance); + assertEquals(List.of("Setup"), labels(rebuilt.find("joke").orElseThrow() + .children())); + } + + // -- 2. Static nested group -- + + public static class Settings { + + @Parameter + private int iterations = 10; + + @Parameter + private double tolerance = 0.01; + } + + public static class WithSettings implements Runnable { + + @Parameter + private String input = "image.tif"; + + @Parameter + private Settings settings = new Settings(); + + @Override + public void run() {} + } + + @Test + public void testStaticNestedGroup() { + final ExecutableInstance instance = instanceOf(new WithSettings()); + final ParameterTree tree = ParameterTree.of(instance.parameters(), + instance); + assertEquals(List.of("Iterations", "Tolerance"), labels(tree.find( + "settings").orElseThrow().children())); + } + + // -- 3. An "advanced" toggle revealing a group -- + + @Group(name = "Advanced", collapsible = true, collapsed = true, + visibleWhen = "showAdvanced") + public static class WithAdvanced implements Runnable { + + @Parameter + private String file = "data.csv"; + + @Parameter(label = "Show advanced options") + private boolean advanced = false; + + @Parameter(group = "Advanced") + private int threads = 4; + + @Parameter(group = "Advanced") + private boolean verbose = false; + + @SuppressWarnings("unused") + private boolean showAdvanced() { + return advanced; + } + + @Override + public void run() {} + } + + @Test + public void testAdvancedGroupAppearsWhenToggled() { + final WithAdvanced command = new WithAdvanced(); + final ExecutableInstance instance = instanceOf(command); + + // Hidden to begin with: the group is absent, not merely empty. + ParameterTree tree = ParameterTree.of(instance.parameters(), instance); + assertEquals(List.of("File", "Show advanced options"), labels(tree + .nodes())); + + // Toggle it on. + final ObservableStruct observable = new ObservableStruct(instance + .parameters()); + observable.set("advanced", true); + tree = ParameterTree.of(instance.parameters(), instance); + + assertEquals(List.of("File", "Show advanced options", "Advanced"), labels( + tree.nodes())); + final ParameterNode advanced = tree.nodes().get(2); + assertTrue(advanced.isGroup()); + assertTrue(advanced.isCollapsible()); + assertTrue(advanced.isCollapsed()); + assertEquals(List.of("Threads", "Verbose"), labels(advanced.children())); + } + + // -- 4. A group whose size depends on a value -- + + @Group(name = "Dimensions", membersFrom = "dimensionMembers", + after = "numDims") + public static class WithDimensions implements Runnable { + + @Parameter + private String dataset = "stack.tif"; + + @Parameter(callback = "dimsChanged") + private int numDims = 2; + + /** + * The generated parameters, rebuilt whenever the count changes. + *

+ * NB: these cannot be fields. A class's fields are fixed when it is + * compiled, and the number wanted here is not known until the user types + * it, so they are built to order over a map - the same primitive a script + * header needs. + *

+ */ + private StructInstance> dims = buildDims(2); + + @SuppressWarnings("unused") + private void dimsChanged() { + dims = buildDims(numDims); + } + + @SuppressWarnings("unused") + private Object dimensionMembers() { + return dims; + } + + private static StructInstance> buildDims( + final int count) + { + final Parameters.Builder builder = Parameters.builder(); + for (int i = 0; i < count; i++) + builder.add("dim" + i, String.class, "d" + i); + return builder.build(); + } + + @Override + public void run() {} + } + + @Test + public void testGeneratedGroupFollowsAValue() { + final WithDimensions command = new WithDimensions(); + final ExecutableInstance instance = instanceOf(command); + + ParameterTree tree = ParameterTree.of(instance.parameters(), instance); + assertEquals(List.of("Dataset", "Num dims", "Dimensions"), labels(tree + .nodes())); + // Two dimensions to begin with. + assertEquals(List.of("Dim0", "Dim1"), labels(tree.nodes().get(2) + .children())); + + // Ask for four: the callback rebuilds the group, and the tree follows. + final ParameterModel model = new ParameterModel(instance); + model.set("numDims", 4); + tree = model.tree(); + assertEquals(List.of("Dim0", "Dim1", "Dim2", "Dim3"), labels(tree.nodes() + .get(2).children())); + } + + // -- Helpers -- + + private static ExecutableInstance instanceOf(final Runnable object) { + final Executable executable = Executables.executableOf(object); + return executable.create(); + } + + private static List labels(final List nodes) { + return nodes.stream().map(ParameterNode::label).collect(Collectors + .toList()); + } +} diff --git a/imagej-compat/pom.xml b/imagej-compat/pom.xml new file mode 100644 index 000000000..f4782e79a --- /dev/null +++ b/imagej-compat/pom.xml @@ -0,0 +1,294 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-imagej-compat + 1.0.1-SNAPSHOT + + SciJava ImageJ Compat + Teaching SciJava3 about ImageJ2's types. + https://github.com/scijava/scijava + 2023 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + None + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.imagej.compat + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-types + ${scijava-types.version} + + + net.imagej + imagej-common + + + + net.imglib2 + imglib2 + + + + + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.scijava + scijava-ops-api + ${scijava-ops-api.version} + test + + + org.scijava + scijava-ops-engine + ${scijava-ops-engine.version} + test + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + + + + + scijava.public + https://maven.scijava.org/content/groups/public + + + + + + + + maven-surefire-plugin + + @{argLine} --add-reads org.scijava.imagej.compat=org.scijava.ops.api + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-antrun-plugin + + + union-op-yaml + process-test-classes + + run + + + + + + + + + + + + + + + + only-eclipse-scijava + + + m2e.version + + + + + + + + org.eclipse.m2e + lifecycle-mapping + + 1.0.0 + + + + + + + maven-antrun-plugin + + run + + + + + true + + true + + + + + + + + + + + + + diff --git a/imagej-compat/src/main/java/module-info.java b/imagej-compat/src/main/java/module-info.java new file mode 100644 index 000000000..c4d408bb3 --- /dev/null +++ b/imagej-compat/src/main/java/module-info.java @@ -0,0 +1,49 @@ +/*- + * #%L + * Interoperability with legacy SciJava libraries. + * %% + * Copyright (C) 2023 - 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% + */ + +/** + * NB: this is the only component here that knows about ImageJ2, and it exists + * so that nothing else has to. Its one class teaches {@code scijava-types} how + * to read the generic type of a {@code Dataset}, which an op needs when it is + * handed an ImageJ2 image. It is a candidate for moving into an ImageJ + * repository, being entirely about ImageJ2's types. + */ +open module org.scijava.imagej.compat { + + exports org.scijava.imagej.compat; + + requires net.imagej; + requires net.imglib2; + requires org.scijava.types; + requires org.scijava.priority; + + provides org.scijava.types.extract.TypeExtractor with + org.scijava.imagej.compat.DatasetTypeExtractor; + +} diff --git a/scijava-legacy/src/main/java/org/scijava/legacy/types/DatasetTypeExtractor.java b/imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java similarity index 98% rename from scijava-legacy/src/main/java/org/scijava/legacy/types/DatasetTypeExtractor.java rename to imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java index 0a84139d3..89ae552cf 100644 --- a/scijava-legacy/src/main/java/org/scijava/legacy/types/DatasetTypeExtractor.java +++ b/imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.legacy.types; +package org.scijava.imagej.compat; import net.imagej.Dataset; import org.scijava.priority.Priority; diff --git a/scijava-legacy/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor b/imagej-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor similarity index 73% rename from scijava-legacy/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor rename to imagej-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor index 8e5ecef8a..54d448f5a 100644 --- a/scijava-legacy/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor +++ b/imagej-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor @@ -1,3 +1,3 @@ # -- DO NOT EDIT -- This file is autogenerated # Instead, modify and re-run /bin/generate-meta-inf.sh from the top-level -org.scijava.legacy.types.DatasetTypeExtractor +org.scijava.imagej.compat.DatasetTypeExtractor diff --git a/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java b/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java similarity index 87% rename from scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java rename to imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java index a49ed96a0..4a1a91a71 100644 --- a/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java +++ b/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.legacy.types; +package org.scijava.imagej.compat; import java.lang.reflect.Type; import java.util.ServiceLoader; @@ -54,10 +54,11 @@ public class DatasetTypeExtractorTest { @Test public void testDatasetTypeExtractor() { // Create a TypeReifier - // NB the use of Discoverer.all(ServiceLoader::load) finds no TypeExtractors - // because the scijava-legacy module does not use the TypeExtractor - // interface. Thus it cannot actually reify anything. - var reifier = new DefaultTypeReifier(Discoverer.all(ServiceLoader::load)); + // NB the use of Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()) + // finds no TypeExtractors because this module does not declare + // "uses org.scijava.types.extract.TypeExtractor", so ServiceLoader + // refuses the lookup. Thus the reifier cannot actually reify anything. + var reifier = new DefaultTypeReifier(Discoverer.all(Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()))); // Create a Dataset var img = ArrayImgs.unsignedBytes(10, 10); var imgPlus = new ImgPlus<>(img); diff --git a/index/pom.xml b/index/pom.xml new file mode 100644 index 000000000..b1a56607b --- /dev/null +++ b/index/pom.xml @@ -0,0 +1,153 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-index + 1.0.0-SNAPSHOT + + SciJava Index + Annotation indexing, for discovery without class loading. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.index + + + none + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/index/src/main/java/module-info.java b/index/src/main/java/module-info.java new file mode 100644 index 000000000..15a6c51e7 --- /dev/null +++ b/index/src/main/java/module-info.java @@ -0,0 +1,41 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.index { + + exports org.scijava.index; + + requires java.compiler; + requires transitive org.scijava.discovery; + requires org.scijava.priority; + + provides javax.annotation.processing.Processor with + org.scijava.index.AnnotationProcessor; + +} diff --git a/index/src/main/java/org/scijava/index/AbstractIndexWriter.java b/index/src/main/java/org/scijava/index/AbstractIndexWriter.java new file mode 100644 index 000000000..0e66dd2a1 --- /dev/null +++ b/index/src/main/java/org/scijava/index/AbstractIndexWriter.java @@ -0,0 +1,352 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentSkipListMap; + +import javax.lang.model.element.AnnotationValue; + +/** + * Writes annotations as JSON-formatted files. + *

+ * The file names are the names of the annotations, and the serialized data + * describe the class which was annotated together with the specific annotation + * fields. + *

+ * + * @author Johannes Schindelin + */ +public abstract class AbstractIndexWriter { + + private final Map> map = + new ConcurrentSkipListMap<>(); + + protected synchronized boolean foundAnnotations() { + return !map.isEmpty(); + } + + protected synchronized void add(final Map annotationValues, + final String annotationName, final String className) + { + Map list = map.get(annotationName); + if (list == null) { + list = new LinkedHashMap<>(); + map.put(annotationName, list); + } + final Map o = new TreeMap<>(); + o.put("class", className); + o.put("values", annotationValues); + list.put(className, o); + } + + public interface StreamFactory { + + InputStream openInput(String annotationName) throws IOException; + + OutputStream openOutput(String annotationName) throws IOException; + + boolean isClassObsolete(String className); + } + + protected synchronized void write(final StreamFactory factory) + throws IOException + { + for (Entry> entry : map.entrySet()) { + final String annotationName = entry.getKey(); + merge(annotationName, factory); + final PrintStream out = + new PrintStream(factory.openOutput(annotationName)); + for (Object o : entry.getValue().values()) { + writeObject(out, adapt(o)); + } + out.close(); + } + map.clear(); + } + + /** + * Merges an existing annotation index into the currently-generated one. + *

+ * This method is used to read previously-indexed annotations and reconcile + * them with the newly-generated ones just. + *

+ * + * @param annotationName the name of the annotation for which the index + * contains the annotated classes + * @param factory the factory to generate input and output streams given an + * annotation name + * @throws IOException + */ + protected synchronized void merge(final String annotationName, + final StreamFactory factory) throws IOException + { + final InputStream in = factory.openInput(annotationName); + if (in == null) { + return; + } + Map m = map.get(annotationName); + if (m == null) { + m = new LinkedHashMap<>(); + map.put(annotationName, m); + } + /* + * To determine whether the index needs to be written out, + * we need to keep track of changed entries. + */ + int changedCount = m.size(); + boolean hasObsoletes = false; + + final IndexReader reader = + new IndexReader(in, annotationName + " from " + in); + try { + for (;;) { + @SuppressWarnings("unchecked") + final Map entry = (Map) reader.next(); + if (entry == null) { + break; + } + final String className = (String) entry.get("class"); + if (factory.isClassObsolete(className)) { + hasObsoletes = true; + } + else if (m.containsKey(className)) { + if (!hasObsoletes && entry.equals(m.get(className))) { + changedCount--; + } + } + else { + m.put(className, entry); + } + } + } + finally { + reader.close(); + } + // if this annotation index is unchanged, no need to write it out again + if (changedCount == 0 && !hasObsoletes) { + map.remove(annotationName); + } + } + + protected Object adapt(final Object o) { + if (o instanceof Annotation) { + return adapt((Annotation) o); + } + else if (o instanceof AnnotationValue) { + return adapt(((AnnotationValue) o).getValue()); + } + else if (o instanceof Enum) { + return adapt((Enum) o); + } + return o; + } + + protected Map adapt(A annotation) { + Map result = new TreeMap<>(); + for (Method method : annotation.annotationType().getMethods()) + try { + if (method.getDeclaringClass() == annotation.annotationType()) { + result.put(method.getName(), adapt(method.invoke(annotation))); + } + } + catch (IllegalArgumentException e) { + e.printStackTrace(); + } + catch (IllegalAccessException e) { + e.printStackTrace(); + } + catch (InvocationTargetException e) { + e.printStackTrace(); + } + return result; + } + + private static Map adapt(Enum e) { + Map result = new TreeMap<>(); + result.put("enum", e.getClass().getName()); + result.put("value", e.name()); + return result; + } + + private void writeObject(final PrintStream out, final Object o) + throws IOException + { + if (o == null) { + out.print("null"); + } + else if (o instanceof Boolean) { + out.print((Boolean) o ? "true" : "false"); + } + else if (o instanceof Byte) { + out.print((byte) (Byte) o); + } + else if (o instanceof Short) { + out.print((short) (Short) o); + } + else if (o instanceof Integer) { + out.print((int) (Integer) o); + } + else if (o instanceof Long) { + out.print((long) (Long) o); + } + else if (o instanceof Float) { + out.print((float) (Float) o); + } + else if (o instanceof Double) { + out.print((double) (Double) o); + } + else if (o instanceof Character) { + writeString(out, "" + o); + } + else if (o instanceof String) { + writeString(out, (String) o); + } + else if (o instanceof Class) { + writeString(out, ((Class) o).getName()); + } + else if (o instanceof List) { + writeArray(out, (List) o); + } + else if (o.getClass().isArray()) { + writeArray(out, o); + } + else if (o instanceof Map) { + writeMap(out, (Map) o); + } + else { + throw new IOException("Cannot handle object of type " + o.getClass()); + } + } + + protected void writeMap(final PrintStream out, final Object... pairs) + throws IOException + { + if ((pairs.length % 2) != 0) { + throw new IOException("Key without value!"); + } + out.write('{'); + for (int i = 0; i < pairs.length; i += 2) { + if (i > 0) { + out.write(','); + } + writeString(out, (String) pairs[i]); + out.write(':'); + writeObject(out, pairs[i + 1]); + } + out.write('}'); + } + + private void writeMap(final PrintStream out, final Map m) + throws IOException + { + out.write('{'); + boolean first = true; + for (Map.Entry entry : m.entrySet()) { + if (first) { + first = false; + } + else { + out.write(','); + } + writeString(out, entry.getKey().toString()); + out.write(':'); + writeObject(out, entry.getValue()); + } + out.write('}'); + } + + private void writeArray(final PrintStream out, final List list) + throws IOException + { + out.write('['); + boolean first = true; + for (Object o : list) { + if (first) { + first = false; + } + else { + out.write(','); + } + o = adapt(o); + writeObject(out, o); + } + out.write(']'); + } + + private void writeArray(final PrintStream out, final Object o) + throws IOException + { + out.write('['); + int length = Array.getLength(o); + for (int i = 0; i < length; i++) { + if (i > 0) { + out.write(','); + } + writeObject(out, adapt(Array.get(o, i))); + } + out.write(']'); + } + + private void writeString(final PrintStream out, final String string) { + out.write('"'); + for (char c : string.toCharArray()) { + if (c == '"' || c == '\\') { + out.write('\\'); + out.write(c); + } + else if (c >= ' ' && c <= 0x7f) { + out.write(c); + } + else { + String hex = Integer.toHexString(c); + out.print("\\u"); + if (hex.length() < 4) { + out.print("0000".substring(hex.length())); + } + out.print(hex); + } + } + out.write('"'); + } + +} diff --git a/index/src/main/java/org/scijava/index/AnnotationProcessor.java b/index/src/main/java/org/scijava/index/AnnotationProcessor.java new file mode 100644 index 000000000..0e65b519d --- /dev/null +++ b/index/src/main/java/org/scijava/index/AnnotationProcessor.java @@ -0,0 +1,296 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.file.NoSuchFileException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.TreeMap; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Name; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.Diagnostic.Kind; +import javax.tools.FileObject; +import javax.tools.StandardLocation; + +import org.scijava.index.AbstractIndexWriter.StreamFactory; + +/** + * An annotation processor for indexing {@link Indexable} annotations at + * compile time. The design is inspired by + * Sezpoz, a compile-time + * indexing library by Jesse Glick. + *

+ * For each annotated class, this processor writes a JSON record of the + * annotation's attributes into {@code META-INF/json/} under a file named after + * the annotation type. At runtime, the index can be read to discover annotated + * classes and inspect their annotation values without loading (and therefore + * initializing) those classes — avoiding the cost and side-effects of a full + * classpath scan. + *

+ *

+ * For example, SciJava Common's {@code org.scijava.plugin.Plugin} annotation + * enables the {@code org.scijava.Context} application container to discover + * and prepare the application including all its plugins without needing to + * load all of those plugins in advance. Rather, they can be loaded upon first + * use in each appropriate context, greatly reducing application startup cost. + *

+ * + * @author Johannes Schindelin + */ +@SupportedAnnotationTypes("*") +public class AnnotationProcessor extends AbstractProcessor { + + private RoundEnvironment roundEnv; + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latestSupported(); + } + + @Override + public boolean process(final Set elements, + final RoundEnvironment env) + { + roundEnv = env; + + final Writer writer = new Writer(); + for (final TypeElement element : elements) { + writer.add(element); + } + try { + writer.write(writer); + } + catch (final IOException e) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + e.printStackTrace(new PrintStream(out)); + try { + out.close(); + processingEnv.getMessager().printMessage(Kind.ERROR, out.toString()); + } + catch (final IOException e2) { + processingEnv.getMessager().printMessage(Kind.ERROR, + e2.getMessage() + " while printing " + e.getMessage()); + } + } + return false; + } + + private class Writer extends AbstractIndexWriter implements StreamFactory { + + private final Map> originatingElements = + new HashMap<>(); + private final Filer filer = processingEnv.getFiler(); + private final Elements utils = processingEnv.getElementUtils(); + private final Types typeUtils = processingEnv.getTypeUtils(); + + public void add(final TypeElement element) { + final AnnotationMirror mirror = getMirror(element); + if (mirror != null) { + final String annotationName = utils.getBinaryName(element).toString(); + + // remember originating elements + List originating = originatingElements.get(annotationName); + if (originating == null) { + originating = new ArrayList<>(); + originatingElements.put(annotationName, originating); + } + + for (final Element annotated : roundEnv + .getElementsAnnotatedWith(element)) + { + switch (annotated.getKind()) { + case ANNOTATION_TYPE: + case CLASS: + case ENUM: + case INTERFACE: + final String className = + utils.getBinaryName((TypeElement) annotated).toString(); + final Map values = + adapt(annotated.getAnnotationMirrors(), element.asType()); + super.add(values, annotationName, className); + originating.add(annotated); + break; + default: + processingEnv.getMessager().printMessage( + Kind.ERROR, + "Cannot handle annotated element of kind " + + annotated.getKind()); + } + } + } + // + } + + @SuppressWarnings("unchecked") + private Map adapt( + final List mirrors, + final TypeMirror annotationType) + { + final Map result = new TreeMap<>(); + for (final AnnotationMirror mirror : mirrors) { + if (typeUtils.isSameType(mirror.getAnnotationType(), annotationType)) { + return (Map) adapt(mirror); + } + } + return result; + } + + @Override + protected Object adapt(final Object o) { + if (o instanceof AnnotationMirror) { + final AnnotationMirror mirror = (AnnotationMirror) o; + final Map result = new TreeMap<>(); + for (final Entry entry : mirror + .getElementValues().entrySet()) + { + final String key = entry.getKey().getSimpleName().toString(); + final Object value = adapt(entry.getValue().getValue()); + result.put(key, value); + } + return result; + } + else if (o instanceof List) { + final List list = (List) o; + final List result = new ArrayList<>(list.size()); + for (final Object item : list) { + result.add(adapt(item)); + } + return result; + } + else if (o instanceof TypeMirror) { + final TypeMirror mirror = (TypeMirror) o; + return utils.getBinaryName((TypeElement) typeUtils.asElement(mirror)) + .toString(); + } + else if (o instanceof VariableElement) { + final VariableElement element = (VariableElement) o; + final Map result = new TreeMap<>(); + final String enumName = + utils.getBinaryName((TypeElement) element.getEnclosingElement()) + .toString(); + final String valueName = element.getSimpleName().toString(); + result.put("enum", enumName); + result.put("value", valueName); + return result; + } + else { + return super.adapt(o); + } + } + + private AnnotationMirror getMirror(final TypeElement element) { + for (final AnnotationMirror candidate : utils + .getAllAnnotationMirrors(element)) + { + final Name binaryName = + utils.getBinaryName((TypeElement) candidate.getAnnotationType() + .asElement()); + if (binaryName.contentEquals(Indexable.class.getName())) { + return candidate; + } + } + return null; + } + + @Override + public InputStream openInput(final String annotationName) + throws IOException + { + try { + return filer.getResource(StandardLocation.CLASS_OUTPUT, "", + Index.INDEX_PREFIX + annotationName).openInputStream(); + } + catch (final FileNotFoundException | NoSuchFileException e) { + return null; + } + } + + @Override + public OutputStream openOutput(final String annotationName) + throws IOException + { + final List originating = originatingElements.get(annotationName); + final String path = Index.INDEX_PREFIX + annotationName; + final FileObject fileObject = + filer.createResource(StandardLocation.CLASS_OUTPUT, "", path, + originating.toArray(new Element[originating.size()])); + + // Verify that the generated file is in the META-INF/json/ subdirectory; + // Despite our asking for it explicitly, the DefaultFileManager will + // strip out the directory if javac was called without an explicit + // output directory (i.e. without -d option). + final String uri = fileObject.toUri().toString(); + if (uri != null && uri.endsWith("/" + path)) { + return fileObject.openOutputStream(); + } + final String prefix = + uri == null ? "" : uri.substring(0, uri.length() - + annotationName.length()); + final File file = new File(prefix + path); + final File parent = file.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Could not create directory: " + parent); + } + return new FileOutputStream(file); + } + + @Override + public boolean isClassObsolete(final String className) { + return false; + } + + } +} diff --git a/index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java b/index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java new file mode 100644 index 000000000..9972206e5 --- /dev/null +++ b/index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java @@ -0,0 +1,352 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.TreeMap; + +/** + * An analyzer to parse {@code @Plugin} annotations inside a {@code .class} + * file without loading the class. The idea is to inspect the classfile to parse + * the annotation attributes. + * + * @author Johannes Schindelin + */ +class ByteCodeAnalyzer { + + private byte[] buffer; + private int[] poolOffsets; + private int endOffset; + private Attribute[] attributes; + + private ByteCodeAnalyzer(final byte[] buffer) { + this.buffer = buffer; + if ((int) getU4(0) != 0xcafebabe) throw new RuntimeException("No class"); + getConstantPoolOffsets(); + // skip interfaces + endOffset += 8 + 2 * getU2(endOffset + 6); + // skip fields + int fieldCount = getU2(endOffset); + endOffset += 2; + for (int i = 0; i < fieldCount; i++) { + endOffset = skipAttributes(endOffset + 6); + } + // skip methods + int methodCount = getU2(endOffset); + endOffset += 2; + for (int i = 0; i < methodCount; i++) { + endOffset = skipAttributes(endOffset + 6); + } + getAllAttributes(); + } + + private String getStringConstant(final int index) { + return getString(poolOffsets[index - 1]); + } + + private long getIntegerConstant(final int index) { + final int offset = poolOffsets[index - 1]; + if (getU1(offset) != 3) throw new RuntimeException("Constant " + index + + " does not refer to an integer"); + return getU4(offset + 1); + } + + private long getLongConstant(final int index) { + final int offset = poolOffsets[index - 1]; + if (getU1(offset) != 5) throw new RuntimeException("Constant " + index + + " does not refer to a long"); + return (getU4(offset + 1) << 32) | getU4(offset + 5); + } + + private float getFloatConstant(final int index) { + final int offset = poolOffsets[index - 1]; + if (getU1(offset) != 4) throw new RuntimeException("Constant " + index + + " does not refer to a float"); + return Float.intBitsToFloat((int) getU4(offset + 1)); + } + + private double getDoubleConstant(final int index) { + final int offset = poolOffsets[index - 1]; + if (getU1(offset) != 6) throw new RuntimeException("Constant " + index + + " does not refer to a double"); + return Double.longBitsToDouble((getU4(offset + 1) << 32) | + getU4(offset + 5)); + } + + // See https://en.wikipedia.org/wiki/Java_class_file#The_constant_pool for the + // meaning of the offsets behind these numbers + private void getConstantPoolOffsets() { + final int poolCount = getU2(8) - 1; + poolOffsets = new int[poolCount]; + int offset = 10; + for (int i = 0; i < poolCount; i++) { + poolOffsets[i] = offset; + final int tag = getU1(offset); + if (tag == 7 || tag == 8 || tag == 16) offset += 3; + else if (tag == 15) offset += 4; + else if (tag == 3 || tag == 4 || tag == 9 || tag == 10 + || tag == 11 || tag == 12 || tag == 18) offset += 5; + else if (tag == 5 || tag == 6) { + poolOffsets[++i] = offset; + offset += 9; + } + else if (tag == 1) offset += 3 + getU2(offset + 1); + else throw new RuntimeException("Unknown tag" + " " + tag); + } + endOffset = offset; + } + + private int getU1(final int offset) { + return getU1(buffer, offset); + } + + private int getU2(final int offset) { + return getU2(buffer, offset); + } + + private long getU4(final int offset) { + return getU4(buffer, offset); + } + + private static int getU1(final byte[] buffer, final int offset) { + return buffer[offset] & 0xff; + } + + private static int getU2(final byte[] buffer, final int offset) { + return getU1(buffer, offset) << 8 | getU1(buffer, offset + 1); + } + + private static long getU4(final byte[] buffer, final int offset) { + return ((long) getU2(buffer, offset)) << 16 | getU2(buffer, offset + 2); + } + + private String getString(final int offset) { + try { + return new String(buffer, offset + 3, getU2(offset + 1), "UTF-8"); + } + catch (final Exception e) { + return ""; + } + } + + private int skipAttributes(int offset) { + int count = getU2(offset); + offset += 2; + for (int i = 0; i < count; i++) { + offset += 6 + getU4(offset + 2); + } + return offset; + } + + private void getAllAttributes() { + attributes = getAttributes(endOffset); + } + + private Attribute[] getAttributes(final int offset) { + final Attribute[] result = new Attribute[getU2(offset)]; + for (int i = 0; i < result.length; i++) + result[i] = + new Attribute(i == 0 ? offset + 2 : result[i - 1].attributeEndOffset); + return result; + } + + private class Attribute { + + int nameIndex; + byte[] attribute; + int attributeEndOffset; + + private Attribute(final int offset) { + nameIndex = getU2(offset); + attribute = new byte[(int) getU4(offset + 2)]; + System.arraycopy(buffer, offset + 6, attribute, 0, attribute.length); + attributeEndOffset = offset + 6 + attribute.length; + } + + private String getName() { + return getStringConstant(nameIndex); + } + } + + private Map> getAnnotations() { + final Map> annotations = + new TreeMap<>(); + for (final Attribute attr : attributes) { + if ("RuntimeVisibleAnnotations".equals(attr.getName())) { + final byte[] buf = attr.attribute; + int count = getU2(buf, 0); + int offset = 2; + for (int i = 0; i < count; i++) { + final String className = + raw2className(getStringConstant(getU2(buf, offset))); + offset += 2; + final Map values = + new TreeMap<>(); + annotations.put(className, values); + offset = parseAnnotationValues(buf, offset, values); + } + } + } + return annotations; + } + + private int parseAnnotationValues(final byte[] buf, int offset, + final Map values) + { + int count = getU2(buf, offset); + offset += 2; + for (int i = 0; i < count; i++) { + final String key = getStringConstant(getU2(buf, offset)); + offset += 2; + offset = parseAnnotationValue(buf, offset, values, key); + } + return offset; + } + + private int parseAnnotationValue(byte[] buf, int offset, + Map map, String key) + { + Object value; + switch (getU1(buf, offset++)) { + case 'Z': + value = Boolean.valueOf(getIntegerConstant(getU2(buf, offset)) != 0); + offset += 2; + break; + case 'B': + value = Byte.valueOf((byte) getIntegerConstant(getU2(buf, offset))); + offset += 2; + break; + case 'C': + value = + Character.valueOf((char) getIntegerConstant(getU2(buf, offset))); + offset += 2; + break; + case 'S': + value = + Short.valueOf((short) getIntegerConstant(getU2(buf, offset))); + offset += 2; + break; + case 'I': + value = + Integer.valueOf((int) getIntegerConstant(getU2(buf, offset))); + offset += 2; + break; + case 'J': + value = Long.valueOf(getLongConstant(getU2(buf, offset))); + offset += 2; + break; + case 'F': + value = Float.valueOf(getFloatConstant(getU2(buf, offset))); + offset += 2; + break; + case 'D': + value = Double.valueOf(getDoubleConstant(getU2(buf, offset))); + offset += 2; + break; + case 's': + value = getStringConstant(getU2(buf, offset)); + offset += 2; + break; + case 'c': + value = raw2className(getStringConstant(getU2(buf, offset))); + offset += 2; + break; + case '[': { + final Object[] array = new Object[getU2(buf, offset)]; + offset += 2; + for (int i = 0; i < array.length; i++) { + offset = parseAnnotationValue(buf, offset, map, key); + array[i] = map.get(key); + } + value = array; + break; + } + case 'e': { + final Map enumValue = + new TreeMap<>(); + enumValue.put("enum", raw2className(getStringConstant(getU2(buf, + offset)))); + offset += 2; + enumValue.put("value", getStringConstant(getU2(buf, offset))); + offset += 2; + value = enumValue; + break; + } + case '@': { + // skipping annotation type + offset += 2; + final Map values = new TreeMap<>(); + offset = parseAnnotationValues(buf, offset, values); + value = values; + break; + } + default: + throw new RuntimeException("Unhandled annotation value type: " + + (char) getU1(buf, offset - 1)); + } + if (value == null) { + throw new NullPointerException(); + } + map.put(key, value); + return offset; + } + + private static String raw2className(final String rawName) { + if (!rawName.startsWith("L") || !rawName.endsWith(";")) { + throw new RuntimeException("Invalid raw class name: " + rawName); + } + return rawName.substring(1, rawName.length() - 1).replace('/', '.'); + } + + private static byte[] readFile(final File file) throws IOException { + final InputStream in = new FileInputStream(file); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + final byte[] buffer = new byte[16384]; + for (;;) { + int count = in.read(buffer); + if (count < 0) break; + out.write(buffer, 0, count); + } + in.close(); + out.close(); + return out.toByteArray(); + } + + static Map> getAnnotations(File file) + throws IOException + { + return new ByteCodeAnalyzer(readFile(file)).getAnnotations(); + } +} diff --git a/index/src/main/java/org/scijava/index/DirectoryIndexer.java b/index/src/main/java/org/scijava/index/DirectoryIndexer.java new file mode 100644 index 000000000..5c3a89d3f --- /dev/null +++ b/index/src/main/java/org/scijava/index/DirectoryIndexer.java @@ -0,0 +1,187 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.annotation.Annotation; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Map; + +/** + * Indexes a directory of classes. + *

+ * The assumption is that the classes are tested at some stage before packaging. + * That will be the time when we can index the annotations, even if Eclipse + * decided not to run any annotation processor. + *

+ * + * @author Johannes Schindelin + */ +public class DirectoryIndexer extends AbstractIndexWriter { + + public void index(final File directory) throws IOException { + try { + @SuppressWarnings("deprecation") + final URLClassLoader loader = + new URLClassLoader(new URL[] { directory.toURL() }, Thread + .currentThread().getContextClassLoader()); + discoverAnnotations(directory, "", loader); + } + catch (MalformedURLException e) { + throw new IOException(e); + } + write(directory); + } + + protected void discoverAnnotations(final File directory, + final String classNamePrefix, final ClassLoader loader) throws IOException + { + final File[] list = directory.listFiles(); + if (list == null) { + return; + } + for (final File file : list) { + if (file.isDirectory()) { + discoverAnnotations(file, classNamePrefix + file.getName() + ".", + loader); + } + else if (file.isFile()) { + final String fileName = file.getName(); + if (!fileName.endsWith(".class")) { + continue; + } + final String className = + classNamePrefix + fileName.substring(0, fileName.length() - 6); + try { + for (final Map.Entry> entry : ByteCodeAnalyzer + .getAnnotations(file).entrySet()) + { + final String annotationName = entry.getKey(); + try { + if (!isIndexable(loader.loadClass(annotationName))) { + continue; + } + } + catch (ClassNotFoundException e) { + // fall back to the class loader that laoded the directory indexer + if (!isIndexable(Class.forName(annotationName))) { + continue; + } + } + add(entry.getValue(), annotationName, className); + } + } + catch (Throwable e) { + System.err.println("Warning: could not load class '" + className + + "'; skipping"); + e.printStackTrace(); + } + } + } + } + + protected synchronized void add(final A annotation, + final String className) + { + if (!isIndexable(annotation)) { + return; + } + add(adapt(annotation), annotation.annotationType().getName(), className); + } + + private static boolean isIndexable(final A annotation) + { + return isIndexable(annotation.annotationType()); + } + + private static boolean isIndexable(final Class annotationClass) { + return annotationClass.getAnnotation(Indexable.class) != null; + } + + protected synchronized void write(final File directory) throws IOException { + final StreamFactory factory = new StreamFactory() { + + @Override + public InputStream openInput(String annotationName) throws IOException { + final File file = + new File(directory, Index.INDEX_PREFIX + annotationName); + if (file.exists()) { + return new FileInputStream(file); + } + return null; + } + + @Override + public OutputStream openOutput(String annotationName) throws IOException { + final File file = + new File(directory, Index.INDEX_PREFIX + annotationName); + final File dir = file.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Could not make directory " + dir); + } + return new FileOutputStream(file) { + + @Override + public void close() throws IOException { + super.close(); + if (file.length() == 0) { + file.delete(); + } + } + }; + } + + @Override + public boolean isClassObsolete(String className) { + final String classPath = className.replace('.', '/') + ".class"; + return !new File(directory, classPath).exists(); + } + }; + + final File[] possiblyObsoletes = + new File(directory, Index.INDEX_PREFIX).listFiles(); + if (possiblyObsoletes != null) { + for (final File candidate : possiblyObsoletes) { + if (candidate.isFile()) { + final String annotationName = candidate.getName(); + merge(annotationName, factory); + } + } + } + write(factory); + } +} diff --git a/index/src/main/java/org/scijava/index/Index.java b/index/src/main/java/org/scijava/index/Index.java new file mode 100644 index 000000000..1ecdd24ea --- /dev/null +++ b/index/src/main/java/org/scijava/index/Index.java @@ -0,0 +1,188 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.net.URL; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** + * Makes the annotation indexes accessible. + *

+ * You would call it like this: + *

+ * + *
+ * for (IndexItem<MyAnnotation> item : Index.load(MyAnnotation.class)) {
+ *   // do something with item.annotation() and/or item.className()
+ * }
+ * 
+ * + * @author Johannes Schindelin + */ +public class Index
implements Iterable> { + + /** + * Loads the index of all classes annotated with the specified annotation. + *

+ * The specified annotation needs to be annotated with {@link Indexable} for + * the annotation indexing to work properly, of course. + *

+ * + * @param annotation the annotation type + * @return the index + */ + public static
Index load(final Class annotation) + { + return load(annotation, Thread.currentThread().getContextClassLoader()); + } + + /** + * Loads the index of all classes annotated with the specified annotation. + * + * @param annotation the annotation type + * @param loader the class loader to use when loading {@link Class}-type + * annotation fields + * @return the index + */ + public static Index load(final Class annotation, + final ClassLoader loader) + { + return new Index<>(annotation, loader); + } + + static final String INDEX_PREFIX = "META-INF/json/"; + + private final Class annotation; + private final ClassLoader loader; + + private Index(final Class annotation, final ClassLoader loader) { + this.annotation = annotation; + this.loader = loader; + } + + private class IndexItemIterator implements Iterator> { + + private Enumeration urls; + private IndexReader indexReader; + private IndexItem next; + private Set seen; + + public IndexItemIterator(final Class annotation) { + seen = new HashSet<>(); + try { + urls = loader.getResources(INDEX_PREFIX + annotation.getName()); + readNext(); + } + catch (final IOException e) { + e.printStackTrace(); + } + } + + private void readNext() throws IOException { + for (;;) { + if (indexReader == null) { + try { + indexReader = getNextReader(); + } catch (IOException e) { + e.printStackTrace(); + continue; + } + if (indexReader == null) { + urls = null; + next = null; + return; + } + } + @SuppressWarnings("unchecked") + final Map map = + (Map) indexReader.next(); + if (map != null) { + final String className = (String) map.get("class"); + @SuppressWarnings("unchecked") + final Map values = + (Map) map.get("values"); + next = new IndexItem<>(annotation, loader, className, values); + return; + } + indexReader.close(); + indexReader = null; + } + } + + private IndexReader getNextReader() throws IOException { + if (urls == null) { + return null; + } + while (urls.hasMoreElements()) { + final URL url = urls.nextElement(); + if (seen.contains(url)) continue; + seen.add(url); + return new IndexReader(url.openStream()); + } + return null; + } + + @Override + public boolean hasNext() { + return next != null; + } + + @Override + public IndexItem next() { + final IndexItem result = next; + try { + readNext(); + } + catch (final IOException e) { + e.printStackTrace(); + next = null; + } + return result; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + } + + @Override + public Iterator> iterator() { + return new IndexItemIterator(annotation); + } +} diff --git a/index/src/main/java/org/scijava/index/IndexDiscoverer.java b/index/src/main/java/org/scijava/index/IndexDiscoverer.java new file mode 100644 index 000000000..a7aa3f528 --- /dev/null +++ b/index/src/main/java/org/scijava/index/IndexDiscoverer.java @@ -0,0 +1,164 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.ToDoubleFunction; + +import org.scijava.discovery.Discoverer; +import org.scijava.discovery.Discovery; +import org.scijava.priority.Priority; + +/** + * A {@link Discoverer} backed by an annotation {@link Index}. + *

+ * This is the discovery mechanism that can answer what is available + * without loading a single implementation class: the index records each + * implementation's class name and its annotation values, so filtering, + * sorting and display all happen on metadata alone. Only + * {@link Discovery#type()} and {@link Discovery#get()} touch the class. + *

+ *

+ * Which type an indexed implementation provides has to be answered from that + * metadata too, hence {@code typeNameOf}: given an indexed item, it yields the + * name of the type that item provides. For an annotation in the shape of + * {@code @Plugin(type = Service.class)}, that is + * {@code item -> item.annotation().type().getName()} - which loads the + * declared type, never the implementation. + *

+ * + * @param
the indexed annotation type + * @author Curtis Rueden + */ +public class IndexDiscoverer implements Discoverer { + + private final Class annotation; + private final ClassLoader classLoader; + private final Function, String> typeNameOf; + private final Function, Map> attrsOf; + private final ToDoubleFunction> priorityOf; + private final Function, ?> instantiator; + + /** + * Creates a discoverer over the index of the given annotation. + * + * @param annotation the indexed annotation + * @param typeNameOf yields the name of the type an indexed item provides + */ + public IndexDiscoverer(final Class annotation, + final Function, String> typeNameOf) + { + this(annotation, typeNameOf, item -> Collections.emptyMap(), + item -> Priority.NORMAL, null, null); + } + + /** + * Creates a discoverer over the index of the given annotation. + * + * @param annotation the indexed annotation + * @param typeNameOf yields the name of the type an indexed item provides + * @param attrsOf yields the metadata of an indexed item + * @param priorityOf yields the priority of an indexed item + * @param classLoader the class loader to query, or null for the thread's + * context class loader + */ + public IndexDiscoverer(final Class annotation, + final Function, String> typeNameOf, + final Function, Map> attrsOf, + final ToDoubleFunction> priorityOf, + final ClassLoader classLoader) + { + this(annotation, typeNameOf, attrsOf, priorityOf, classLoader, null); + } + + /** + * Creates a discoverer over the index of the given annotation. + * + * @param annotation the indexed annotation + * @param typeNameOf yields the name of the type an indexed item provides + * @param attrsOf yields the metadata of an indexed item + * @param priorityOf yields the priority of an indexed item + * @param classLoader the class loader to query, or null for the thread's + * context class loader + * @param instantiator constructs a discovered object, or null to construct + * it here with the no-argument constructor + *

+ * NB: this matters under JPMS. Reflective construction is checked + * against the module performing it, so a class in a package opened + * only to some container cannot be constructed by this module on + * that container's behalf. A container passes an instantiator + * defined in its own module, and users open their implementation + * packages to that container rather than to this one. + *

+ */ + public IndexDiscoverer(final Class
annotation, + final Function, String> typeNameOf, + final Function, Map> attrsOf, + final ToDoubleFunction> priorityOf, + final ClassLoader classLoader, final Function, ?> instantiator) + { + this.annotation = annotation; + this.typeNameOf = typeNameOf; + this.attrsOf = attrsOf; + this.priorityOf = priorityOf; + this.classLoader = classLoader; + this.instantiator = instantiator; + } + + @Override + public List> discover(final Class c) { + final ClassLoader loader = classLoader != null ? classLoader : // + Thread.currentThread().getContextClassLoader(); + final List> discoveries = new ArrayList<>(); + for (final IndexItem item : Index.load(annotation, loader)) { + final String typeName; + try { + typeName = typeNameOf.apply(item); + } + catch (final RuntimeException exc) { + // NB: a malformed index entry must not sink every other discovery. + continue; + } + if (!c.getName().equals(typeName)) continue; + @SuppressWarnings("unchecked") + final Function, ? extends U> construct = + instantiator == null ? null // + : t -> (U) instantiator.apply(t); + discoveries.add(Discovery.of(item.className(), c, loader, // + attrsOf.apply(item), priorityOf.applyAsDouble(item), construct)); + } + return discoveries; + } +} diff --git a/index/src/main/java/org/scijava/index/IndexItem.java b/index/src/main/java/org/scijava/index/IndexItem.java new file mode 100644 index 000000000..509857cf8 --- /dev/null +++ b/index/src/main/java/org/scijava/index/IndexItem.java @@ -0,0 +1,242 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; + +/** + * Allows access to individual annotations. + * + * @author Johannes Schindelin + */ +public class IndexItem { + + private final Class annotation; + private final ClassLoader loader; + private final String className; + private final Map map; + + IndexItem(final Class annotation, final ClassLoader loader, + final String className, final Map map) + { + this.annotation = annotation; + this.loader = loader; + this.className = className; + this.map = map; + } + + /** + * Obtains the annotation values. + * + * @return the annotation values + */ + public A annotation() { + return proxy(annotation, loader, className, map); + } + + /** + * Returns the name of the annotated class. + * + * @return the name of the annotated class. + */ + public String className() { + return className; + } + + @SuppressWarnings("unchecked") + private static A proxy(final Class annotation, + final ClassLoader loader, final String className, + final Map map) + { + return (A) Proxy.newProxyInstance(loader, new Class[] { annotation }, + new InvocationHandler() { + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable + { + if (map.containsKey(method)) { + return map.get(method); + } + final String name = method.getName(); + Object value; + if (map.containsKey(name)) { + value = map.get(name); + final Class expectedType = method.getReturnType(); + if (!expectedType.isAssignableFrom(value.getClass())) { + value = adapt(value, loader, expectedType, className); + } + } + else if (name.equals("toString") && + (args == null || args.length == 0)) + { + value = "@" + annotation.getName() + map; + } + else if (name.equals("annotationType") && + (args == null || args.length == 0)) + { + value = annotation; + } + else if (name.equals("hashCode") && + (args == null || args.length == 0)) + { + return annotation.hashCode() ^ map.hashCode(); + } + else if (name.equals("equals") && args != null && args.length == 1) { + if (!(args[0] instanceof Annotation) || + ((Annotation) args[0]).annotationType() != annotation) + { + return false; + } + for (Method method2 : annotation.getMethods()) { + if (method2.getDeclaringClass() == annotation) { + if (!invoke(proxy, method2, new Object[0]).equals( + method2.invoke(args[0]))) + { + return false; + } + } + } + return true; + } + else { + value = method.getDefaultValue(); + if (value == null) { + throw new IllegalArgumentException("Could not find value for " + + name); + } + } + map.put(method, value); + return value; + } + }); + } + + private static Object adapt(final Object o, final ClassLoader loader, + final Class expectedType, final String className) + { + if (o == null) { + return null; + } + else if (expectedType.isAssignableFrom(o.getClass())) { + return o; + } + else if (o instanceof Boolean) { + return (boolean) (Boolean) o; + } + else if (o instanceof Long) { + long l = (Long) o; + if (expectedType == Byte.TYPE) { + return (byte) l; + } + else if (expectedType == Short.TYPE) { + return (short) l; + } + else if (expectedType == Integer.TYPE) { + return (int) l; + } + else if (expectedType == Long.TYPE) { + return l; + } + } + else if (o instanceof Double) { + double d = (Double) o; + if (expectedType == Float.TYPE) { + return (float) d; + } + return d; + } + else if (expectedType == Character.TYPE) { + String s = (String) o; + if (s.length() == 1) { + return s.charAt(0); + } + } + else if (expectedType == Class.class) { + try { + return loader.loadClass((String) o); + } + catch (Throwable t) { + throw cce(o, expectedType, className, t); + } + } + else if (expectedType.isArray()) { + @SuppressWarnings("unchecked") + List list = (List) o; + Class type = expectedType.getComponentType(); + int length = list.size(); + Object array = Array.newInstance(type, length); + for (int i = 0; i < length; i++) { + Array.set(array, i, adapt(list.get(i), loader, type, className)); + } + return array; + } + else if (Enum.class.isAssignableFrom(expectedType)) { + @SuppressWarnings("unchecked") + final Map map = (Map) o; + final String enumName = (String) map.get("enum"); + final String constName = (String) map.get("value"); + try { + return loader.loadClass(enumName).getField(constName).get(null); + } + catch (Throwable t) { + throw cce(o, expectedType, className, t); + } + } + else if (Annotation.class.isAssignableFrom(expectedType)) { + @SuppressWarnings("unchecked") + final Class annotation = (Class) expectedType; + @SuppressWarnings("unchecked") + final Map map = (Map) o; + return proxy(annotation, loader, className, map); + } + throw cce(o, expectedType, className, null); + } + + private static ClassCastException cce(final Object o, + final Class expectedType, final String className, final Throwable cause) + { + final String oType = o == null ? "" : o.getClass().getName(); + final String eType = + expectedType == null ? "" : expectedType.getName(); + final ClassCastException cce = + new ClassCastException(className + ": cannot cast object of type " + + oType + " to " + eType); + if (cause != null) cce.initCause(cause); + return cce; + } + +} diff --git a/index/src/main/java/org/scijava/index/IndexReader.java b/index/src/main/java/org/scijava/index/IndexReader.java new file mode 100644 index 000000000..2d660412f --- /dev/null +++ b/index/src/main/java/org/scijava/index/IndexReader.java @@ -0,0 +1,222 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PushbackInputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +/** + * Reads indexed annotations in JSON format. + *

+ * Example: an annotation {@code @Simple(string1 = "World")} to the + * {@code Annotated} class would be serialized as + * + *

+ * {
+ *     "class":"Annotated",
+ *     "values":{
+ *         "string1":"World"
+ *     }
+ * }
+ * 
+ * + *

+ * + * @author Johannes Schindelin + */ +class IndexReader { + + private final PushbackInputStream in; + private final String originalISName; + + IndexReader(final InputStream in) { + this(in, ""); + } + + IndexReader(final InputStream in, final String isName) { + this.in = + in instanceof PushbackInputStream ? (PushbackInputStream) in + : new PushbackInputStream(new BufferedInputStream(in)); + this.originalISName = isName; + } + + public Object next() throws IOException { + int c = in.read(); + while (Character.isWhitespace(c)) c = in.read(); + if (c < 0) { + return null; + } + if (c == '{') { + Map map = new LinkedHashMap<>(); + for (;;) { + if (expect('"', '}') == 1) { + return map; + } + String key = readString(); + expect(':'); + Object value = next(); + map.put(key, value); + if (expect(',', '}') == 1) { + return map; + } + } + } + if (c == '[') { + List list = new ArrayList<>(); + c = in.read(); + if (c == ']') { + return list; + } + in.unread(c); + for (;;) { + Object value = next(); + list.add(value); + if (expect(',', ']') == 1) { + return list; + } + } + } + if (c == 't') { + expect("rue"); + return true; + } + if (c == 'f') { + expect("alse"); + return false; + } + if (c == 'n') { + expect("ull"); + throw new IOException("Invalid stream: contains null"); + } + if (c == '.' || c == '-' || (c >= '0' && c <= '9')) { + boolean isInteger = true; + StringBuilder builder = new StringBuilder(); + for (;;) { + builder.append((char) c); + c = in.read(); + if (c == '.' || c == 'e' || c == 'E') { + isInteger = false; + } + else if (c < '0' || c > '9') { + in.unread(c); + break; + } + } + c = in.read(); + if (c == 'I' && "-".equals(builder.toString())) { + expect("nfinity"); + return Double.NEGATIVE_INFINITY; + } + in.unread(c); + if (isInteger) { + return Long.parseLong(builder.toString()); + } + return (double) Double.parseDouble(builder.toString()); + } + if (c == 'I') { + expect("nfinity"); + return Double.POSITIVE_INFINITY; + } + if (c == 'N') { + expect("aN"); + return Double.NaN; + } + if (c == '"') { + return readString(); + } + throw new IOException("Unexpected char: '" + (char) c + "'"+ + ((originalISName.length()>0) ? " from "+originalISName : "")); + } + + public void close() throws IOException { + in.close(); + } + + private String readString() throws IOException { + StringBuilder builder = new StringBuilder(); + for (;;) { + int c = in.read(); + if (c == '"') { + return builder.toString(); + } + builder.append((char) readCharacter(c)); + } + } + + private int readCharacter(int first) throws IOException { + int c = first; + if (c == '\\') { + c = in.read(); + if (c == 'u') { + String hex = + "" + ((char) in.read()) + ((char) in.read()) + ((char) in.read()) + + ((char) in.read()); + c = Integer.parseInt(hex, 16); + } + else if (c != '\\' && c != '"') { + throw new IOException("Expected '\"' or '\\', got '" + c + "'"); + } + } + return c; + } + + private void expect(char expect) throws IOException { + int c = in.read(); + if (c != expect) { + throw new IOException("Expected '" + expect + "', got '" + (char) c + "'"); + } + } + + private int expect(char a, char b) throws IOException { + int c = in.read(); + if (c == a) { + return 0; + } + if (c == b) { + return 1; + } + throw new IOException("Expected '" + a + "' or '" + b + "', got '" + + (char) c + "'"+((originalISName.length()>0) ? " from "+originalISName : "")); + } + + private void expect(String match) throws IOException { + for (char c : match.toCharArray()) { + expect(c); + } + } + +} diff --git a/index/src/main/java/org/scijava/index/Indexable.java b/index/src/main/java/org/scijava/index/Indexable.java new file mode 100644 index 000000000..d638fb6d0 --- /dev/null +++ b/index/src/main/java/org/scijava/index/Indexable.java @@ -0,0 +1,48 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Meta annotation for indexable annotations. + *

+ * All annotations that want to be indexed need to be annotated with + * {@code @Indexable}. + *

+ * + * @author Johannes Schindelin + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) +public @interface Indexable {} diff --git a/index/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/index/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 000000000..839ead7a6 --- /dev/null +++ b/index/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +org.scijava.index.AnnotationProcessor diff --git a/index/src/test/java/org/scijava/index/AnnotationProcessorTest.java b/index/src/test/java/org/scijava/index/AnnotationProcessorTest.java new file mode 100644 index 000000000..0a481d221 --- /dev/null +++ b/index/src/test/java/org/scijava/index/AnnotationProcessorTest.java @@ -0,0 +1,218 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests {@link AnnotationProcessor} by compiling a class the way a downstream + * project would: with this component on the annotation processor path, and + * nothing else configured. + *

+ * This is the mechanism users actually depend on - annotate a class, and it is + * discoverable with no configuration files to write - so it is worth testing + * through javac rather than by calling the indexer directly. + *

+ * + * @author Curtis Rueden + */ +public class AnnotationProcessorTest { + + @Test + public void testProcessorWritesIndex(@TempDir final Path outputDir) + throws IOException + { + final String source = "" + // + "package test;\n" + // + "import org.scijava.index.Shape;\n" + // + "import org.scijava.index.Widget;\n" + // + "@Widget(type = Shape.class, label = \"A triangle\")\n" + // + "public class Triangle implements Shape {\n" + // + " public String describe() { return \"triangle\"; }\n" + // + "}\n"; + + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + final List options = Arrays.asList( // + "-classpath", System.getProperty("java.class.path"), // + "-processorpath", System.getProperty("java.class.path"), // + "-processor", AnnotationProcessor.class.getName(), // + "-d", outputDir.toString()); + final boolean compiled = compiler.getTask(null, null, null, options, null, + List.of(new SourceString("test.Triangle", source))).call(); + assertTrue(compiled, "test source failed to compile"); + + // The processor must have written an index for the annotation. + final Path index = outputDir.resolve("META-INF/json/" + Widget.class + .getName()); + assertTrue(Files.exists(index), "no index written to " + index); + final String json = new String(Files.readAllBytes(index), + StandardCharsets.UTF_8); + assertTrue(json.contains("test.Triangle"), "index lacks the class: " + + json); + assertTrue(json.contains("A triangle"), "index lacks the metadata: " + + json); + + // ...and the index must be readable, without loading the class. + final ClassLoader loader = new URLClassLoader( // + new URL[] { outputDir.toUri().toURL() }, getClass().getClassLoader()); + final IndexDiscoverer discoverer = new IndexDiscoverer<>( // + Widget.class, item -> item.annotation().type().getName(), // + item -> java.util.Map.of("label", item.annotation().label()), // + item -> item.annotation().priority(), loader); + final List found = discoverer.discover(Shape.class).stream() // + .map(d -> d.implClassName()) // + .filter(n -> n.startsWith("test.")) // + .collect(java.util.stream.Collectors.toList()); + assertEquals(List.of("test.Triangle"), found); + } + + /** + * Two indexable annotations on one class are indexed independently, each to + * its own file. + *

+ * This is what lets presentation metadata stack onto a plugin declaration - + * {@code @Plugin} plus {@code @Menu} - instead of every plugin type's + * attributes piling up in one annotation. A consumer wanting both reads + * both indexes and joins on the class name, with no class loading and no + * bytecode parsing. + *

+ */ + @Test + public void testStackedAnnotationsAreIndexedSeparately( + @TempDir final Path outputDir) throws IOException + { + final String menuAnnotation = "" + // + "package test;\n" + // + "import java.lang.annotation.*;\n" + // + "import org.scijava.index.Indexable;\n" + // + "@Indexable\n" + // + "@Retention(RetentionPolicy.RUNTIME)\n" + // + "@Target(ElementType.TYPE)\n" + // + "public @interface Menu { String path(); double weight() default 0; }\n"; + final String command = "" + // + "package test;\n" + // + "import org.scijava.index.Shape;\n" + // + "import org.scijava.index.Widget;\n" + // + "@Widget(type = Shape.class, label = \"A pentagon\")\n" + // + "@Menu(path = \"Image>Adjust\", weight = 12)\n" + // + "public class Pentagon implements Shape {\n" + // + " public String describe() { return \"pentagon\"; }\n" + // + "}\n"; + + final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + final List options = Arrays.asList( // + "-classpath", compilePath(), // + "-processorpath", compilePath(), // + "-processor", AnnotationProcessor.class.getName(), // + "-d", outputDir.toString()); + final javax.tools.DiagnosticCollector diags = + new javax.tools.DiagnosticCollector<>(); + final boolean compiled = compiler.getTask(null, null, diags, options, null, + List.of(new SourceString("test.Menu", menuAnnotation), // + new SourceString("test.Pentagon", command))).call(); + assertTrue(compiled, "test sources failed to compile: " + diags + .getDiagnostics()); + + // Each annotation gets its own index, keyed by the same class. + final Path widgetIndex = outputDir.resolve("META-INF/json/" + Widget.class + .getName()); + final Path menuIndex = outputDir.resolve("META-INF/json/test.Menu"); + assertTrue(Files.exists(widgetIndex), "no widget index"); + assertTrue(Files.exists(menuIndex), "no menu index"); + + final String menuJson = new String(Files.readAllBytes(menuIndex), + StandardCharsets.UTF_8); + assertTrue(menuJson.contains("test.Pentagon"), menuJson); + assertTrue(menuJson.contains("Image>Adjust"), menuJson); + assertTrue(menuJson.contains("12"), menuJson); + } + + /** + * Gets a class path covering both this component's classes and its test + * classes. + *

+ * NB: {@code java.class.path} is not enough. These tests run on the module + * path, so the component's own classes are not on the class path at all, + * and a source importing {@code Indexable} would not compile. + *

+ */ + private static String compilePath() { + return location(Indexable.class) + File.pathSeparator + location( + AnnotationProcessorTest.class) + File.pathSeparator + System.getProperty( + "java.class.path"); + } + + private static String location(final Class c) { + try { + return new File(c.getProtectionDomain().getCodeSource().getLocation() + .toURI()).getAbsolutePath(); + } + catch (final java.net.URISyntaxException exc) { + throw new AssertionError(exc); + } + } + + /** A source file held in a string. */ + private static class SourceString extends SimpleJavaFileObject { + + private final String code; + + SourceString(final String className, final String code) { + super(URI.create("string:///" + className.replace('.', '/') + + Kind.SOURCE.extension), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(final boolean ignoreEncodingErrors) { + return code; + } + } +} diff --git a/index/src/test/java/org/scijava/index/Circle.java b/index/src/test/java/org/scijava/index/Circle.java new file mode 100644 index 000000000..ef6c42919 --- /dev/null +++ b/index/src/test/java/org/scijava/index/Circle.java @@ -0,0 +1,40 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +/** Another {@link Shape}, of lower priority than {@link Square}. */ +@Widget(type = Shape.class, label = "A circle", priority = -5) +public class Circle implements Shape { + + @Override + public String describe() { + return "circle"; + } +} diff --git a/index/src/test/java/org/scijava/index/Color.java b/index/src/test/java/org/scijava/index/Color.java new file mode 100644 index 000000000..90382ad15 --- /dev/null +++ b/index/src/test/java/org/scijava/index/Color.java @@ -0,0 +1,35 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +/** A second type, to prove discovery filters by provided type. */ +public interface Color { + String name(); +} diff --git a/index/src/test/java/org/scijava/index/IndexDiscovererTest.java b/index/src/test/java/org/scijava/index/IndexDiscovererTest.java new file mode 100644 index 000000000..7b2a3aca5 --- /dev/null +++ b/index/src/test/java/org/scijava/index/IndexDiscovererTest.java @@ -0,0 +1,193 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.scijava.discovery.Discovery; + +/** + * Tests {@link IndexDiscoverer}, and with it the property that makes an + * annotation index worth having: it reports what is available, and what its + * metadata says, without loading any implementation class. + * + * @author Curtis Rueden + */ +public class IndexDiscovererTest { + + private static final String SQUARE = "org.scijava.index.Square"; + + /** + * Indexes the test classes. + *

+ * This component cannot run its own annotation processor while compiling + * itself, so the index is built here instead, exactly as SciJava Common + * builds its own. + *

+ */ + @BeforeAll + public static void buildIndex() throws Exception { + final File testClasses = new File(Square.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + new DirectoryIndexer().index(testClasses); + } + + private static IndexDiscoverer discoverer() { + return new IndexDiscoverer<>(Widget.class, // + item -> item.annotation().type().getName(), // + item -> { + final Map attrs = new HashMap<>(); + attrs.put("label", item.annotation().label()); + return attrs; + }, // + item -> item.annotation().priority(), // + Thread.currentThread().getContextClassLoader()); + } + + /** Discovery reads metadata without constructing anything. */ + @Test + public void testDiscoveryDoesNotConstruct() { + final List> discoveries = discoverer().discover( + Shape.class); + + final Discovery square = byClassName(discoveries, Square.class + .getName()); + assertNotNull(square, "Square was not discovered"); + assertEquals("A square", square.attr("label").orElse(null)); + assertEquals(10.0, square.priority()); + assertFalse(Square.constructed, + "discovery must not construct the implementation"); + + // Only now does the class come into existence. + assertEquals("square", square.get().describe()); + assertTrue(Square.constructed); + } + + /** + * The whole point: discovery never asks the class loader for the + * implementation class, so an index-backed discoverer can answer what is + * available without paying to load it. + *

+ * NB: this cannot be shown with a static flag on the implementation, since + * reading such a flag would itself load the class. It takes a class loader + * that records what it is asked for. + *

+ */ + @Test + public void testDiscoveryDoesNotLoadClasses() { + final RecordingClassLoader loader = new RecordingClassLoader( // + Thread.currentThread().getContextClassLoader()); + final IndexDiscoverer discoverer = new IndexDiscoverer<>( // + Widget.class, item -> item.annotation().type().getName(), // + item -> Map.of(), item -> item.annotation().priority(), loader); + + final List> discoveries = discoverer.discover(Shape.class); + final Discovery square = byClassName(discoveries, SQUARE); + assertNotNull(square); + assertFalse(loader.loaded.contains(SQUARE), + "discovery must not load the implementation class"); + + // Asking for the object is what loads it. + assertNotNull(square.get()); + assertTrue(loader.loaded.contains(SQUARE)); + } + + /** Discovery yields only implementations of the requested type. */ + @Test + public void testFiltersByProvidedType() { + final List shapes = discoverer().discover(Shape.class).stream() // + .map(Discovery::implClassName) // + .sorted() // + .collect(Collectors.toList()); + assertEquals(List.of(Circle.class.getName(), Square.class.getName()), + shapes); + + final List colors = discoverer().discover(Color.class).stream() // + .map(Discovery::implClassName) // + .collect(Collectors.toList()); + assertEquals(List.of(Red.class.getName()), colors); + } + + /** An unindexed type yields nothing rather than failing. */ + @Test + public void testUnknownTypeYieldsNothing() { + assertTrue(discoverer().discover(Runnable.class).isEmpty()); + } + + /** Priorities come from the index, so they can sort without loading. */ + @Test + public void testPriorityFromMetadata() { + final List> sorted = discoverer().discover(Shape.class) + .stream() // + .sorted((a, b) -> Double.compare(b.priority(), a.priority())) // + .collect(Collectors.toList()); + assertEquals(Square.class.getName(), sorted.get(0).implClassName()); + assertEquals(Circle.class.getName(), sorted.get(1).implClassName()); + } + + /** A class loader that records every class it is asked to load. */ + private static class RecordingClassLoader extends ClassLoader { + + private final Set loaded = ConcurrentHashMap.newKeySet(); + + RecordingClassLoader(final ClassLoader parent) { + super(parent); + } + + @Override + protected Class loadClass(final String name, final boolean resolve) + throws ClassNotFoundException + { + loaded.add(name); + return super.loadClass(name, resolve); + } + } + + private static Discovery byClassName( + final List> discoveries, final String className) + { + return discoveries.stream() // + .filter(d -> className.equals(d.implClassName())) // + .findFirst().orElse(null); + } +} diff --git a/index/src/test/java/org/scijava/index/Red.java b/index/src/test/java/org/scijava/index/Red.java new file mode 100644 index 000000000..31bf83408 --- /dev/null +++ b/index/src/test/java/org/scijava/index/Red.java @@ -0,0 +1,40 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +/** A {@link Color}, which must not turn up when discovering {@link Shape}s. */ +@Widget(type = Color.class, label = "Red") +public class Red implements Color { + + @Override + public String name() { + return "red"; + } +} diff --git a/index/src/test/java/org/scijava/index/Shape.java b/index/src/test/java/org/scijava/index/Shape.java new file mode 100644 index 000000000..c9b4d592a --- /dev/null +++ b/index/src/test/java/org/scijava/index/Shape.java @@ -0,0 +1,35 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +/** A type that test widgets provide. */ +public interface Shape { + String describe(); +} diff --git a/index/src/test/java/org/scijava/index/Square.java b/index/src/test/java/org/scijava/index/Square.java new file mode 100644 index 000000000..6d9d3ceb1 --- /dev/null +++ b/index/src/test/java/org/scijava/index/Square.java @@ -0,0 +1,49 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +/** + * A {@link Shape} that records whether it has ever been constructed, so that + * tests can prove discovery does not instantiate what it finds. + */ +@Widget(type = Shape.class, label = "A square", priority = 10) +public class Square implements Shape { + + public static volatile boolean constructed = false; + + public Square() { + constructed = true; + } + + @Override + public String describe() { + return "square"; + } +} diff --git a/index/src/test/java/org/scijava/index/Widget.java b/index/src/test/java/org/scijava/index/Widget.java new file mode 100644 index 000000000..b213f1c53 --- /dev/null +++ b/index/src/test/java/org/scijava/index/Widget.java @@ -0,0 +1,50 @@ +/* + * #%L + * Annotation indexing, for discovery without class loading. + * %% + * Copyright (C) 2026 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.index; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** A test annotation, in the shape of a plugin annotation. */ +@Indexable +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Widget { + + /** The type of thing this widget provides. */ + Class type(); + + /** A label, to prove that metadata is readable without class loading. */ + String label() default ""; + + double priority() default 0; +} diff --git a/io3/pom.xml b/io3/pom.xml new file mode 100644 index 000000000..6383442ad --- /dev/null +++ b/io3/pom.xml @@ -0,0 +1,148 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-io3 + + 3.0.0-SNAPSHOT + + SciJava I/O + Locations and data handles: a uniform way to address and read bytes. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.io3 + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + + + + + + org.scijava + scijava-collections + ${scijava-collections.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/io3/src/main/java/module-info.java b/io3/src/main/java/module-info.java new file mode 100644 index 000000000..1fae009af --- /dev/null +++ b/io3/src/main/java/module-info.java @@ -0,0 +1,49 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.io3 { + + exports org.scijava.io3; + exports org.scijava.io3.handle; + exports org.scijava.io3.location; + + requires org.scijava.collections; + + uses org.scijava.io3.handle.DataHandle; + uses org.scijava.io3.location.LocationResolver; + + provides org.scijava.io3.handle.DataHandle with + org.scijava.io3.handle.FileHandle, + org.scijava.io3.handle.BytesHandle, + org.scijava.io3.handle.DummyHandle; + + provides org.scijava.io3.location.LocationResolver with + org.scijava.io3.location.FileLocationResolver; + +} diff --git a/io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java b/io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java new file mode 100644 index 000000000..8e17b8d25 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java @@ -0,0 +1,154 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3; + +import org.scijava.collections.ByteArray; + +/** + * {@link ByteBank} implementation backed by a {@link ByteArray}. Self-growing + * up to a maximum capacity of {@link Integer#MAX_VALUE}. + * + * @author Gabriel Einsdorf + */ +public class ByteArrayByteBank implements ByteBank { + + private final ByteArray buffer; + private long size; + + /** + * Creates a {@link ByteArrayByteBank}. + */ + public ByteArrayByteBank() { + this(new ByteArray()); + } + + /** + * Creates a {@link ByteArrayByteBank} with the specified initial capacity. + * + * @param initialCapacity the initial capacity of this {@link ByteBank} + */ + public ByteArrayByteBank(final int initialCapacity) { + this(emptyByteArrayOfCapacity(initialCapacity)); + } + + /** + * Creates a {@link ByteArrayByteBank} that wraps the provided byte array. + * + * @param bytes the bytes to wrap + */ + public ByteArrayByteBank(final byte[] bytes) { + this(new ByteArray(bytes)); + } + + /** + * Creates a {@link ByteArrayByteBank} that wraps the specified + * {@link ByteArray}. + * + * @param bytes the {@link ByteArray} to wrap + */ + public ByteArrayByteBank(final ByteArray bytes) { + buffer = bytes; + size = bytes.size(); + } + + @Override + public long getMaxBufferSize() { + return Integer.MAX_VALUE; + } + + @Override + public void setBytes(final long startpos, final byte[] bytes, + final int offset, final int length) + { + // ensure we have space + checkWritePos(startpos, startpos + length); + final int neededCapacity = (int) (size + length); + buffer.ensureCapacity(neededCapacity); + + // copy the data + System.arraycopy(bytes, offset, buffer.getArray(), (int) startpos, length); + buffer.setSize(neededCapacity); + updateSize(startpos + length); + } + + @Override + public void setByte(final long pos, final byte b) { + checkWritePos(pos, pos); + buffer.ensureCapacity((int) pos); + // NB: update the size of the underlying buffer before appending to it + if (pos == buffer.size()) { + buffer.setSize((int) (pos + 1)); + } + buffer.setValue((int) pos, b); + updateSize(pos + 1); + } + + @Override + public void clear() { + buffer.clear(); + size = 0; + } + + @Override + public byte getByte(final long pos) { + checkReadPos(pos, pos); + // the buffer might contain bytes with negative value + // we need to flip the sign to positive to satisfy the method contract + return buffer.getValue((int) pos); + } + + @Override + public int getBytes(final long startPos, final byte[] b, final int offset, + final int length) + { + checkReadPos(startPos, startPos + length); + // ensure we don't try to read data which is not in the buffer + final int readLength = (int) Math.min(size() - startPos, length); + System.arraycopy(buffer.getArray(), (int) startPos, b, offset, readLength); + return readLength; + } + + @Override + public long size() { + return size; + } + + // -- Helper methods -- + + private void updateSize(final long newSize) { + size = newSize > size ? newSize : size; + } + + private static ByteArray emptyByteArrayOfCapacity(final int capacity) { + final ByteArray byteArray = new ByteArray(new byte[capacity]); + byteArray.setSize(0); + return byteArray; + } +} diff --git a/io3/src/main/java/org/scijava/io3/ByteBank.java b/io3/src/main/java/org/scijava/io3/ByteBank.java new file mode 100644 index 000000000..ff6288fb2 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/ByteBank.java @@ -0,0 +1,209 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3; + +/** + * A {@link ByteBank} is a self-growing buffer over arbitrary bytes. + * + * @author Gabriel Einsdorf + * @author Curtis Rueden + */ +public interface ByteBank { + + /** + * @param pos the position to read from + * @return the byte at the given position + */ + public byte getByte(long pos); + + /** + * @param startPos the position in the buffer to start reading from + * @param bytes the byte array to read into + * @return the number of bytes read + */ + default int getBytes(long startPos, byte[] bytes) { + return getBytes(startPos, bytes, 0, bytes.length); + } + + /** + * @param startPos the position in the buffer to start reading from + * @param bytes the byte array to read into + * @param offset the offset in the bytes array + * @param length the number of elements to read into the bytes array + * @return number of bytes read + */ + int getBytes(long startPos, byte[] bytes, int offset, int length); + + /** + * Copies part of this buffer into a newly allocated byte array. + * + * @param offset the initial position in the buffer + * @param len the number of bytes to copy + * @return The newly allocated byte array containing the data. + */ + default byte[] toByteArray(final long offset, final int len) { + if (offset < 0 || len < 0 || offset + len > size()) { + throw new IllegalArgumentException("Invalid range"); + } + final byte[] bytes = new byte[len]; + getBytes(offset, bytes); + return bytes; + } + + /** + * Copies this entire buffer into a newly allocated byte array. + * + * @return The newly allocated byte array containing the data. + */ + default byte[] toByteArray() { + long max = size(); + if (max > Integer.MAX_VALUE) { + throw new IllegalStateException( + "Byte bank is too large to store into a single byte[]"); + } + return toByteArray(0, (int) max); + } + + /** + * Sets the bytes starting form the given position to the values form the + * provided array. + * + * @param startPos the position in the buffer to start writing from + * @param bytes the byte array to write + * @param offset the offset in the bytes array + * @param length the number of bytes to read + */ + void setBytes(long startPos, byte[] bytes, int offset, int length); + + /** + * Appends the given bytes to the buffer + * + * @param bytes the array containing the bytes to append to the buffer + * @param length the number of elements to append from the bytes array + */ + default void appendBytes(byte[] bytes, int length) { + appendBytes(bytes, 0, length); + } + + /** + * Appends the given bytes to the buffer + * + * @param bytes the array containing the bytes to append to the buffer + * @param offset the offset in the bytes array + * @param length the number of elements to append from the bytes array + */ + default void appendBytes(byte[] bytes, int offset, int length) { + setBytes(size(), bytes, offset, length); + } + + /** + * Check if we can read from the specified range + * + * @param start the start position of the range + * @param end the end position of the range + */ + default void checkReadPos(final long start, final long end) { + basicRangeCheck(start, end); + if (start > size()) { + throw new IndexOutOfBoundsException("Requested position: " + start + + " is outside the buffer: " + size()); + } + } + + /** + * Check if we can write to the specified range + * + * @param start the start position of the range + * @param end the end position of the range + * @throws IndexOutOfBoundsException if + */ + default void checkWritePos(final long start, final long end) { + if (start > size() + 1) { // we can't have holes in the buffer + throw new IndexOutOfBoundsException("Requested start position: " + start + + " would leave a hole in the buffer, largest legal position is: " + + size()); + } + if (end < start) { + throw new IllegalArgumentException( + "Invalid range, end is smaller than start!"); + } + if (end > getMaxBufferSize()) { + throw new IndexOutOfBoundsException("Requested position " + end + + " is larger than the maximal buffer size: " + getMaxBufferSize()); + } + } + + /** + * Ensures that the requested range satisfies basic sanity criteria. + * + * @param start the start of the range + * @param end the end of the range + */ + default void basicRangeCheck(final long start, final long end) { + if (start > size()) { + throw new IndexOutOfBoundsException("Requested position: " + start + + " is outside the buffer: " + size()); + } + if (end < start) { + throw new IllegalArgumentException( + "Invalid range, end is smaller than start!"); + } + } + + /** + * Clears the buffer + */ + void clear(); + + /** + * @return the offset which follows the last byte stored in this ByteBank + */ + long size(); + + /** + * Sets the byte at the given position + * + * @param pos the position + * @param b the value to set + */ + void setByte(long pos, byte b); + + /** + * @return the maximal size of the buffer + */ + long getMaxBufferSize(); + + /** + * @return True iff the buffer is read-only. + */ + default boolean isReadOnly() { + return false; + } +} diff --git a/io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java b/io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java new file mode 100644 index 000000000..b8c96a1a0 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java @@ -0,0 +1,216 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3; + +import java.nio.ByteBuffer; +import java.util.function.Function; + + +/** + * A {@link ByteBank} backed by a {@link ByteBuffer}. Self-growing up to a + * maximal capacity of {@link Integer#MAX_VALUE} + * + * @author Gabriel Einsdorf + */ +public class ByteBufferByteBank implements ByteBank { + + private static final int DEFAULT_CAPACITY = 10_000; + + /** + * Creates a bank backed by an off-heap direct buffer. + *

+ * Direct buffers are what make this bank worth having alongside + * {@link ByteArrayByteBank}: bytes live outside the Java heap, so they can + * be handed to native code and to shared memory without a copy. Appose's + * named shared memory blocks are the motivating case. + *

+ */ + public static ByteBufferByteBank direct() { + return new ByteBufferByteBank(ByteBuffer::allocateDirect); + } + + /** + * Creates a bank backed by an off-heap direct buffer of the given + * initial capacity. + * + * @see #direct() + */ + public static ByteBufferByteBank direct(final int initialCapacity) { + return new ByteBufferByteBank(ByteBuffer::allocateDirect, initialCapacity); + } + + private ByteBuffer buffer; + + private int size; + + private Function provider; + + public ByteBufferByteBank() { + provider = ByteBuffer::allocate; + buffer = provider.apply(DEFAULT_CAPACITY); + } + + public ByteBufferByteBank(final Function provider) { + this.provider = provider; + buffer = provider.apply(DEFAULT_CAPACITY); + } + + public ByteBufferByteBank(final Function provider, + final int initialCapacity) + { + this.provider = provider; + buffer = provider.apply(initialCapacity); + } + + public ByteBufferByteBank(final int initialCapacity) { + provider = ByteBuffer::allocate; + buffer = provider.apply(initialCapacity); + } + + /** + * Gets whether this bank's bytes live off the Java heap, and so can reach + * native code and shared memory without a copy. + * + * @see #direct() + */ + public boolean isDirect() { + return buffer.isDirect(); + } + + @Override + public long getMaxBufferSize() { + return Integer.MAX_VALUE; + } + + @Override + public void setBytes(final long startpos, final byte[] bytes, + final int offset, final int length) + { + // ensure we have space + checkWritePos(startpos, startpos + length); + final int neededCapacity = size + length; + ensureCapacity(neededCapacity); + + // copy the data + buffer.position((int) startpos); + buffer.put(bytes, offset, length); + + // update the maxpos + updateSize(startpos + length); + } + + @Override + public void setByte(final long pos, final byte b) { + checkWritePos(pos, pos); + if (pos == buffer.capacity()) { + ensureCapacity((int) pos + 1); + } + buffer.put((int) pos, b); + updateSize(pos + 1); + } + + @Override + public void clear() { + buffer.clear(); + size = 0; + } + + @Override + public byte getByte(final long pos) { + checkReadPos(pos, pos); + // the buffer might contain bytes with negative value + // we need to flip the sign to positive to satisfy the contract of this + // method + return buffer.get((int) pos); + } + + @Override + public int getBytes(final long startPos, final byte[] b, final int offset, + final int length) + { + checkReadPos(startPos, startPos + length); + // ensure we don't try to read data which is not in the buffer + final int readLength = (int) Math.min(size() - startPos, length); + buffer.position((int) startPos); + buffer.get(b, offset, readLength); + + return readLength; + } + + @Override + public long size() { + return size; + } + + @Override + public boolean isReadOnly() { + // NB: Some ByteBuffers are read-only. But there is no API to check it. + // Therefore, we make a "best effort" guess based on known read-only types. + // Since these read-only types are not public, we compare class names rather + // than checking for type equality or using instanceof. + final String className = buffer.getClass().getName(); + return className.equals("java.nio.HeapByteBufferR") || + className.equals("java.nio.DirectByteBufferR"); + } + + // -- Helper methods -- + + private void ensureCapacity(final int minCapacity) { + final int oldCapacity = buffer.capacity(); + if (minCapacity <= oldCapacity) return; // no need to grow + + // grow the array by up to 50% (plus a small constant) + final int growth = Math.min(oldCapacity / 2 + 16, Integer.MAX_VALUE); + final int newCapacity; + if (growth > Integer.MAX_VALUE - oldCapacity) { + // growth would push array over the maximum array size + newCapacity = Integer.MAX_VALUE; + } + else newCapacity = oldCapacity + growth; + // ensure the array grows by at least the requested minimum capacity + final int newLength = Math.max(minCapacity, newCapacity); + + // copy the data into a new buffer + buffer.position(0); + // NB: allocate through the provider, not ByteBuffer.allocate. Growing a + // direct bank must yield another direct buffer: SciJava Common's version + // quietly moved the bytes back onto the heap here, losing the off-heap + // property that is the whole reason to choose this bank. + final ByteBuffer newBuffer = provider.apply(newLength); + newBuffer.order(buffer.order()); + newBuffer.put(buffer); + buffer = newBuffer; + } + + private void updateSize(final long newSize) { + size = (int) (newSize > size ? newSize : size); + } + +} diff --git a/io3/src/main/java/org/scijava/io3/Bytes.java b/io3/src/main/java/org/scijava/io3/Bytes.java new file mode 100644 index 000000000..9a776fde6 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/Bytes.java @@ -0,0 +1,785 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3; + +/** + * Useful methods for reading, writing, decoding and converting {@code byte}s + * and {@code byte} arrays. + * + * @author Curtis Rueden + * @author Melissa Linkert + * @author Chris Allan + */ +public final class Bytes { + + private Bytes() { + // NB: prevent instantiation of utility class. + } + + // -- Word decoding - bytes to primitive types -- + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code short}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static short toShort(final byte[] bytes, final int off, int len, + final boolean little) + { + if (bytes.length - off < len) len = bytes.length - off; + short total = 0; + for (int i = 0, ndx = off; i < len; i++, ndx++) { + total |= + (bytes[ndx] < 0 ? 256 + bytes[ndx] : (int) bytes[ndx]) << ((little ? i + : len - i - 1) * 8); + } + return total; + } + + /** + * Translates up to the first 2 bytes of a {@code byte} array beyond the given + * offset to a {@code short}. If there are fewer than 2 bytes available the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static short toShort(final byte[] bytes, final int off, + final boolean little) + { + return toShort(bytes, off, 2, little); + } + + /** + * Translates up to the first 2 bytes of a {@code byte} array to a + * {@code short}. If there are fewer than 2 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static short toShort(final byte[] bytes, final boolean little) { + return toShort(bytes, 0, 2, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code short}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static short toShort(final short[] bytes, final int off, int len, + final boolean little) + { + if (bytes.length - off < len) len = bytes.length - off; + short total = 0; + for (int i = 0, ndx = off; i < len; i++, ndx++) { + total |= bytes[ndx] << ((little ? i : len - i - 1) * 8); + } + return total; + } + + /** + * Translates up to the first 2 bytes of a {@code byte} array beyond the given + * offset to a {@code short}. If there are fewer than 2 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static short toShort(final short[] bytes, final int off, + final boolean little) + { + return toShort(bytes, off, 2, little); + } + + /** + * Translates up to the first 2 bytes of a {@code byte} array to a + * {@code short}. If there are fewer than 2 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static short toShort(final short[] bytes, final boolean little) { + return toShort(bytes, 0, 2, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to an {@code int}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static int toInt(final byte[] bytes, final int off, int len, + final boolean little) + { + if (bytes.length - off < len) len = bytes.length - off; + int total = 0; + for (int i = 0, ndx = off; i < len; i++, ndx++) { + total |= + (bytes[ndx] < 0 ? 256 + bytes[ndx] : (int) bytes[ndx]) << ((little ? i + : len - i - 1) * 8); + } + return total; + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array beyond the given + * offset to an {@code int}. If there are fewer than 4 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static int toInt(final byte[] bytes, final int off, + final boolean little) + { + return toInt(bytes, off, 4, little); + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array to an + * {@code int}. If there are fewer than 4 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static int toInt(final byte[] bytes, final boolean little) { + return toInt(bytes, 0, 4, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to an {@code int}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static int toInt(final short[] bytes, final int off, int len, + final boolean little) + { + if (bytes.length - off < len) len = bytes.length - off; + int total = 0; + for (int i = 0, ndx = off; i < len; i++, ndx++) { + total |= bytes[ndx] << ((little ? i : len - i - 1) * 8); + } + return total; + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array beyond the given + * offset to an {@code int}. If there are fewer than 4 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static int toInt(final short[] bytes, final int off, + final boolean little) + { + return toInt(bytes, off, 4, little); + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array to an + * {@code int}. If there are fewer than 4 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static int toInt(final short[] bytes, final boolean little) { + return toInt(bytes, 0, 4, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code float}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static float toFloat(final byte[] bytes, final int off, final int len, + final boolean little) + { + return Float.intBitsToFloat(toInt(bytes, off, len, little)); + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array beyond a given + * offset to a {@code float}. If there are fewer than 4 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static float toFloat(final byte[] bytes, final int off, + final boolean little) + { + return toFloat(bytes, off, 4, little); + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array to a + * {@code float}. If there are fewer than 4 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static float toFloat(final byte[] bytes, final boolean little) { + return toFloat(bytes, 0, 4, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * a given offset to a {@code float}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static float toFloat(final short[] bytes, final int off, + final int len, final boolean little) + { + return Float.intBitsToFloat(toInt(bytes, off, len, little)); + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array beyond a given + * offset to a {@code float}. If there are fewer than 4 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static float toFloat(final short[] bytes, final int off, + final boolean little) + { + return toInt(bytes, off, 4, little); + } + + /** + * Translates up to the first 4 bytes of a {@code byte} array to a + * {@code float}. If there are fewer than 4 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static float toFloat(final short[] bytes, final boolean little) { + return toInt(bytes, 0, 4, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code long}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static long toLong(final byte[] bytes, final int off, int len, + final boolean little) + { + if (bytes.length - off < len) len = bytes.length - off; + long total = 0; + for (int i = 0, ndx = off; i < len; i++, ndx++) { + total |= + (bytes[ndx] < 0 ? 256L + bytes[ndx] : (long) bytes[ndx]) << ((little + ? i : len - i - 1) * 8); + } + return total; + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array beyond the given + * offset to a {@code long}. If there are fewer than 8 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static long toLong(final byte[] bytes, final int off, + final boolean little) + { + return toLong(bytes, off, 8, little); + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array to a + * {@code long}. If there are fewer than 8 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static long toLong(final byte[] bytes, final boolean little) { + return toLong(bytes, 0, 8, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code long}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static long toLong(final short[] bytes, final int off, int len, + final boolean little) + { + if (bytes.length - off < len) len = bytes.length - off; + long total = 0; + for (int i = 0, ndx = off; i < len; i++, ndx++) { + total |= ((long) bytes[ndx]) << ((little ? i : len - i - 1) * 8); + } + return total; + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array beyond the given + * offset to a {@code long}. If there are fewer than 8 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static long toLong(final short[] bytes, final int off, + final boolean little) + { + return toLong(bytes, off, 8, little); + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array to a + * {@code long}. If there are fewer than 8 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static long toLong(final short[] bytes, final boolean little) { + return toLong(bytes, 0, 8, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code double}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static double toDouble(final byte[] bytes, final int off, + final int len, final boolean little) + { + return Double.longBitsToDouble(toLong(bytes, off, len, little)); + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array beyond the given + * offset to a {@code double}. If there are fewer than 8 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static double toDouble(final byte[] bytes, final int off, + final boolean little) + { + return toDouble(bytes, off, 8, little); + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array to a + * {@code double}. If there are fewer than 8 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static double toDouble(final byte[] bytes, final boolean little) { + return toDouble(bytes, 0, 8, little); + } + + /** + * Translates up to the first {@code len} bytes of a {@code byte} array beyond + * the given offset to a {@code double}. If there are fewer than {@code len} + * bytes available, the MSBs are all assumed to be zero (regardless of + * endianness). + */ + public static double toDouble(final short[] bytes, final int off, + final int len, final boolean little) + { + return Double.longBitsToDouble(toLong(bytes, off, len, little)); + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array beyond the given + * offset to a {@code double}. If there are fewer than 8 bytes available, the + * MSBs are all assumed to be zero (regardless of endianness). + */ + public static double toDouble(final short[] bytes, final int off, + final boolean little) + { + return toDouble(bytes, off, 8, little); + } + + /** + * Translates up to the first 8 bytes of a {@code byte} array to a + * {@code double}. If there are fewer than 8 bytes available, the MSBs are all + * assumed to be zero (regardless of endianness). + */ + public static double toDouble(final short[] bytes, final boolean little) { + return toDouble(bytes, 0, 8, little); + } + + // -- Word decoding - primitive types to bytes -- + + /** Translates the {@code short} value into an array of two {@code byte}s. */ + public static byte[] fromShort(final short value, final boolean little) { + final byte[] v = new byte[2]; + unpack(value, v, 0, 2, little); + return v; + } + + /** Translates the {@code int} value into an array of four {@code byte}s. */ + public static byte[] fromInt(final int value, final boolean little) { + final byte[] v = new byte[4]; + unpack(value, v, 0, 4, little); + return v; + } + + /** Translates the {@code float} value into an array of four {@code byte}s. */ + public static byte[] fromFloat(final float value, final boolean little) { + final byte[] v = new byte[4]; + unpack(Float.floatToIntBits(value), v, 0, 4, little); + return v; + } + + /** Translates the {@code long} value into an array of eight {@code byte}s. */ + public static byte[] fromLong(final long value, final boolean little) { + final byte[] v = new byte[8]; + unpack(value, v, 0, 8, little); + return v; + } + + /** Translates the {@code double} value into an array of eight {@code byte}s. */ + public static byte[] fromDouble(final double value, final boolean little) { + final byte[] v = new byte[8]; + unpack(Double.doubleToLongBits(value), v, 0, 8, little); + return v; + } + + /** + * Translates an array of {@code short} values into an array of {@code byte} + * values. + */ + public static byte[] fromShorts(final short[] values, final boolean little) { + final byte[] v = new byte[values.length * 2]; + for (int i = 0; i < values.length; i++) { + unpack(values[i], v, i * 2, 2, little); + } + return v; + } + + /** + * Translates an array of {@code int} values into an array of {@code byte} + * values. + */ + public static byte[] fromInts(final int[] values, final boolean little) { + final byte[] v = new byte[values.length * 4]; + for (int i = 0; i < values.length; i++) { + unpack(values[i], v, i * 4, 4, little); + } + return v; + } + + /** + * Translates an array of {@code float} values into an array of {@code byte} + * values. + */ + public static byte[] fromFloats(final float[] values, final boolean little) { + final byte[] v = new byte[values.length * 4]; + for (int i = 0; i < values.length; i++) { + unpack(Float.floatToIntBits(values[i]), v, i * 4, 4, little); + } + return v; + } + + /** + * Translates an array of {@code long} values into an array of {@code byte} + * values. + */ + public static byte[] fromLongs(final long[] values, final boolean little) { + final byte[] v = new byte[values.length * 8]; + for (int i = 0; i < values.length; i++) { + unpack(values[i], v, i * 8, 8, little); + } + return v; + } + + /** + * Translates an array of {@code double} values into an array of {@code byte} + * values. + */ + public static byte[] fromDoubles(final double[] values, final boolean little) + { + final byte[] v = new byte[values.length * 8]; + for (int i = 0; i < values.length; i++) { + unpack(Double.doubleToLongBits(values[i]), v, i * 8, 8, little); + } + return v; + } + + /** + * Translates {@code nBytes} of the given {@code long} and places the result + * in the given {@code byte} array. + * + * @throws IllegalArgumentException if the specified indices fall outside the + * buffer + */ + public static void unpack(final long value, final byte[] buf, final int ndx, + final int nBytes, final boolean little) + { + if (buf.length < ndx + nBytes) { + throw new IllegalArgumentException("Invalid indices: buf.length=" + + buf.length + ", ndx=" + ndx + ", nBytes=" + nBytes); + } + if (little) { + for (int i = 0; i < nBytes; i++) { + buf[ndx + i] = (byte) ((value >> (8 * i)) & 0xff); + } + } + else { + for (int i = 0; i < nBytes; i++) { + buf[ndx + i] = (byte) ((value >> (8 * (nBytes - i - 1))) & 0xff); + } + } + } + + /** + * Converts a {@code byte} array to the appropriate 1D primitive type array. + * + * @param b Byte array to convert. + * @param bpp Denotes the number of bytes in the returned primitive type (e.g. + * if bpp == 2, we should return an array of type {@code short}). + * @param fp If set and bpp == 4 or bpp == 8, then return {@code float}s or + * {@code double}s. + * @param little Whether {@code byte} array is in little-endian order. + */ + public static Object makeArray(final byte[] b, final int bpp, + final boolean fp, final boolean little) + { + if (bpp == 1) { + return b; + } + else if (bpp == 2) { + final short[] s = new short[b.length / 2]; + for (int i = 0; i < s.length; i++) { + s[i] = toShort(b, i * 2, 2, little); + } + return s; + } + else if (bpp == 4 && fp) { + final float[] f = new float[b.length / 4]; + for (int i = 0; i < f.length; i++) { + f[i] = toFloat(b, i * 4, 4, little); + } + return f; + } + else if (bpp == 4) { + final int[] i = new int[b.length / 4]; + for (int j = 0; j < i.length; j++) { + i[j] = toInt(b, j * 4, 4, little); + } + return i; + } + else if (bpp == 8 && fp) { + final double[] d = new double[b.length / 8]; + for (int i = 0; i < d.length; i++) { + d[i] = toDouble(b, i * 8, 8, little); + } + return d; + } + else if (bpp == 8) { + final long[] l = new long[b.length / 8]; + for (int i = 0; i < l.length; i++) { + l[i] = toLong(b, i * 8, 8, little); + } + return l; + } + return null; + } + + /** + * Converts a {@code byte} array to the appropriate 2D primitive type array. + * + * @param b Byte array to convert. + * @param bpp Denotes the number of bytes in the returned primitive type (e.g. + * if bpp == 2, we should return an array of type {@code short}). + * @param fp If set and bpp == 4 or bpp == 8, then return {@code float}s or + * {@code double}s. + * @param little Whether {@code byte} array is in little-endian order. + * @param height The height of the output primitive array (2nd dim length). + * @return a 2D primitive array of appropriate type, dimensioned + * [height][b.length / (bpp * height)] + * @throws IllegalArgumentException if input {@code byte} array does not + * divide evenly into height pieces + */ + public static Object makeArray2D(final byte[] b, final int bpp, + final boolean fp, final boolean little, final int height) + { + if (b.length % (bpp * height) != 0) { + throw new IllegalArgumentException("Array length mismatch: " + + "b.length=" + b.length + "; bpp=" + bpp + "; height=" + height); + } + final int width = b.length / (bpp * height); + if (bpp == 1) { + final byte[][] b2 = new byte[height][width]; + for (int y = 0; y < height; y++) { + final int index = width * y; + System.arraycopy(b, index, b2[y], 0, width); + } + return b2; + } + else if (bpp == 2) { + final short[][] s = new short[height][width]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final int index = 2 * (width * y + x); + s[y][x] = toShort(b, index, 2, little); + } + } + return s; + } + else if (bpp == 4 && fp) { + final float[][] f = new float[height][width]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final int index = 4 * (width * y + x); + f[y][x] = toFloat(b, index, 4, little); + } + } + return f; + } + else if (bpp == 4) { + final int[][] i = new int[height][width]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final int index = 4 * (width * y + x); + i[y][x] = toInt(b, index, 4, little); + } + } + return i; + } + else if (bpp == 8 && fp) { + final double[][] d = new double[height][width]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final int index = 8 * (width * y + x); + d[y][x] = toDouble(b, index, 8, little); + } + } + return d; + } + else if (bpp == 8) { + final long[][] l = new long[height][width]; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + final int index = 8 * (width * y + x); + l[y][x] = toLong(b, index, 8, little); + } + } + return l; + } + return null; + } + + // -- Byte swapping -- + + public static short swap(final short x) { + return (short) ((x << 8) | ((x >> 8) & 0xFF)); + } + + public static char swap(final char x) { + return (char) ((x << 8) | ((x >> 8) & 0xFF)); + } + + public static int swap(final int x) { + return (swap((short) x) << 16) | (swap((short) (x >> 16)) & 0xFFFF); + } + + public static long swap(final long x) { + return ((long) swap((int) x) << 32) | (swap((int) (x >> 32)) & 0xFFFFFFFFL); + } + + public static float swap(final float x) { + return Float.intBitsToFloat(swap(Float.floatToIntBits(x))); + } + + public static double swap(final double x) { + return Double.longBitsToDouble(swap(Double.doubleToLongBits(x))); + } + + // -- Normalization -- + + /** + * Normalize the given {@code float} array so that the minimum value maps to + * 0.0 and the maximum value maps to 1.0. + */ + public static float[] normalize(final float[] data) { + final float[] rtn = new float[data.length]; + + // determine the finite min and max values + float min = Float.MAX_VALUE; + float max = Float.MIN_VALUE; + for (final float floatValue : data) { + if (floatValue == Float.POSITIVE_INFINITY || + floatValue == Float.NEGATIVE_INFINITY) + { + continue; + } + if (floatValue < min) min = floatValue; + if (floatValue > max) max = floatValue; + } + + // normalize infinity values + for (int i = 0; i < data.length; i++) { + if (data[i] == Float.POSITIVE_INFINITY) data[i] = max; + else if (data[i] == Float.NEGATIVE_INFINITY) data[i] = min; + } + + // now normalize; min => 0.0, max => 1.0 + final float range = max - min; + for (int i = 0; i < rtn.length; i++) { + rtn[i] = (data[i] - min) / range; + } + return rtn; + } + + /** + * Normalize the given {@code double} array so that the minimum value maps to + * 0.0 and the maximum value maps to 1.0. + */ + public static double[] normalize(final double[] data) { + final double[] rtn = new double[data.length]; + + // determine the finite min and max values + double min = Double.MAX_VALUE; + double max = Double.MIN_VALUE; + for (final double doubleValue : data) { + if (doubleValue == Double.POSITIVE_INFINITY || + doubleValue == Double.NEGATIVE_INFINITY) + { + continue; + } + if (doubleValue < min) min = doubleValue; + if (doubleValue > max) max = doubleValue; + } + + // normalize infinity values + for (int i = 0; i < data.length; i++) { + if (data[i] == Double.POSITIVE_INFINITY) data[i] = max; + else if (data[i] == Double.NEGATIVE_INFINITY) data[i] = min; + } + + // now normalize; min => 0.0, max => 1.0 + final double range = max - min; + for (int i = 0; i < rtn.length; i++) { + rtn[i] = (data[i] - min) / range; + } + return rtn; + } + + // -- Signed data conversion -- + + public static byte[] makeSigned(final byte[] b) { + for (int i = 0; i < b.length; i++) { + b[i] = (byte) (b[i] + 128); + } + return b; + } + + public static short[] makeSigned(final short[] s) { + for (int i = 0; i < s.length; i++) { + s[i] = (short) (s[i] + 32768); + } + return s; + } + + public static int[] makeSigned(final int[] i) { + for (int j = 0; j < i.length; j++) { + i[j] = (int) (i[j] + 2147483648L); + } + return i; + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java new file mode 100644 index 000000000..d6a5a7520 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java @@ -0,0 +1,94 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import org.scijava.io3.location.Location; + +/** + * Abstract base class for {@link DataHandle} implementations. + * + * @author Curtis Rueden + */ +public abstract class AbstractDataHandle implements + DataHandle +{ + + private L location; + + // -- DataHandle methods -- + + @Override + public void set(final L location) { + if (!supports(location)) { + throw new IllegalArgumentException("Incompatible location: " + location); + } + this.location = location; + } + + @Override + public L get() { + return location; + } + + private byte[] conversionBuffer = new byte[8]; + + @Override + public byte[] conversionBuffer() { + return conversionBuffer; + } + + // -- Fields -- + + private ByteOrder order = ByteOrder.BIG_ENDIAN; + private String encoding = "UTF-8"; + + // -- DataHandle methods -- + + @Override + public ByteOrder getOrder() { + return order; + } + + @Override + public void setOrder(final ByteOrder order) { + this.order = order; + } + + @Override + public String getEncoding() { + return encoding; + } + + @Override + public void setEncoding(final String encoding) { + this.encoding = encoding; + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java new file mode 100644 index 000000000..a1614c170 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java @@ -0,0 +1,112 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; + +import org.scijava.io3.location.Location; + +/** + * Abstract superclass for {@link DataHandle}s that operate over other + * {@link DataHandle}s. + * + * @author Gabriel Einsdorf + */ +public abstract class AbstractHigherOrderHandle extends + AbstractDataHandle +{ + + private DataHandle handle; + private boolean closed; + + public AbstractHigherOrderHandle(final DataHandle handle) { + this.handle = handle; + set(handle.get()); // provides access to underlying location + } + + @Override + public boolean isReadable() { + return !closed && handle.isReadable(); + } + + @Override + public boolean isWritable() { + return !closed && handle.isWritable(); + } + + @Override + public long length() throws IOException { + ensureOpen(); + return handle.length(); + } + + @Override + public Class locationType() { + return handle.locationType(); + } + + @Override + public boolean exists() throws IOException { + return handle.exists(); + } + + @Override + public void close() throws IOException { + if (!closed) { + cleanup(); + closed = true; + handle.close(); + handle = null; + } + } + + protected void ensureOpen() throws IOException { + if (closed) { + throw new IOException("This handle is closed!"); + } + } + + /** + * Clean up data structures after a handle has been closed in the + * {@link #close()} method. + * + * @throws IOException + */ + protected abstract void cleanup() throws IOException; + + /** + * @return the {@link DataHandle} wrapped by this + * {@link AbstractHigherOrderHandle} + */ + protected DataHandle handle() { + return handle; + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java new file mode 100644 index 000000000..f9cc3c2dd --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java @@ -0,0 +1,103 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; + +import org.scijava.io3.location.Location; + +public abstract class AbstractSeekableStreamHandle extends + AbstractStreamHandle implements SeekableStreamHandle +{ + + private long jumpCutoff = 10000; + + @Override + public void seek(final long pos) throws IOException { + + // how much and which direction we have to jump + final long delta = pos - offset(); + + if (delta == 0) { + return; + // nothing to do + } + else if (delta > 0) { + // offset position is "downstream" + + // try to reconnect instead of linearly reading large chunks + if (recreatePossible() && delta > jumpCutoff) { + recreateStreamFromPos(pos); + } + else { + jump(delta); + } + + } + else { // delta < 0 + // need to recreate the stream + if (recreatePossible()) { + recreateStreamFromPos(pos); + } + else { + resetStream(); + jump(pos); + } + } + setOffset(pos); + } + + /** + * Recreates the internal input stream available through {@link #in()}, so + * that it starts from the specified position. + * + * @param pos + * @throws IOException + */ + protected abstract void recreateStreamFromPos(long pos) throws IOException; + + /** + * In some implementations of this class, the ability to recreate the stream + * depends on external factors (e.g. server support). This influences a + * + * @return if recreate is actually possible. + * @throws IOException + */ + protected abstract boolean recreatePossible() throws IOException; + + /** + * Sets the maximum of bytes which are read from the stream when seeking + * forward. Any larger number will result in a call to + * {@link #recreateStreamFromPos(long)}. + */ + protected void setJumpCutoff(long jumpCutoff) { + this.jumpCutoff = jumpCutoff; + } +} diff --git a/io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java new file mode 100644 index 000000000..3f467d629 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java @@ -0,0 +1,63 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import org.scijava.io3.location.Location; + +/** + * Abstract base class for {@link StreamHandle} implementations. + * + * @author Curtis Rueden + * @author Melissa Linkert + */ +public abstract class AbstractStreamHandle extends + AbstractDataHandle implements StreamHandle +{ + + // -- Fields -- + + /** Current position within the stream(s). */ + private long offset; + + // -- StreamHandle methods -- + + @Override + public void setOffset(final long offset) { + this.offset = offset; + } + + // -- DataHandle methods -- + + @Override + public long offset() { + return offset; + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/BytesHandle.java b/io3/src/main/java/org/scijava/io3/handle/BytesHandle.java new file mode 100644 index 000000000..bc6490785 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/BytesHandle.java @@ -0,0 +1,183 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.EOFException; +import java.io.IOException; + +import org.scijava.io3.ByteBank; +import org.scijava.io3.location.BytesLocation; + +/** + * {@link DataHandle} for a {@link BytesLocation}. + * + * @author Curtis Rueden + * @author Melissa Linkert + * @author Gabriel Einsdorf + */ +public class BytesHandle extends AbstractDataHandle { + + private long offset = 0; + + // -- Constructors -- + + public BytesHandle() { } + + public BytesHandle(final BytesLocation location) { + set(location); + } + + // -- DataHandle methods -- + + @Override + public boolean isReadable() { + return true; + } + + @Override + public boolean isWritable() { + return !bytes().isReadOnly(); + } + + @Override + public boolean exists() { + return true; + } + + @Override + public long offset() { + return offset; + } + + @Override + public long length() { + return bytes().size(); + } + + @Override + public void setLength(final long length) throws IOException { + // check if new length is legal + bytes().basicRangeCheck(0, length); + // TODO update the maxLength? + } + + @Override + public int read(final byte[] b, final int off, int len) throws IOException { + if(len == 0) return 0; + if (offset + len > length()) { + len = (int) (length() - offset); + } + if(len == 0) { // EOF + return -1; + } + bytes().getBytes(offset, b, off, len); + offset += len; + return len; + } + + @Override + public void seek(final long pos) throws IOException { + if (pos > length()) setLength(pos); + offset = pos; + } + + // -- DataInput methods -- + + @Override + public byte readByte() throws IOException { + ensureReadable(1); + try { + // we need to convert the bytes into the range 0-255 + return bytes().getByte(offset++); + } + catch (final Exception e) { + throw eofException(e); + } + } + + @Override + public void readFully(final byte[] b, final int off, final int len) + throws IOException + { + ensureReadable(len); + try { + bytes().getBytes(offset, b, off, len); + offset += len; + } + catch (final Exception e) { + throw eofException(e); + } + } + + // -- DataOutput methods -- + + @Override + public void write(final byte[] b, final int off, final int len) + throws IOException + { + ensureWritable(len); + bytes().setBytes(offset, b, off, len); + offset += len; + } + + @Override + public void write(final int b) throws IOException { + ensureWritable(1); + bytes().setByte(offset, (byte) b); + offset++; + } + + // -- Closeable methods -- + + @Override + public void close() { + // NB: No action needed. + } + + // -- DataHandle methods -- + + @Override + public Class locationType() { + return BytesLocation.class; + } + + // -- Helper methods -- + + private ByteBank bytes() { + return get().getByteBank(); + } + + private EOFException eofException(final Throwable cause) { + final EOFException eof = new EOFException(); + eof.initCause(cause); + return eof; + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/DataHandle.java b/io3/src/main/java/org/scijava/io3/handle/DataHandle.java new file mode 100644 index 000000000..f578c5e17 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/DataHandle.java @@ -0,0 +1,723 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.Closeable; +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Date; + +import org.scijava.io3.Bytes; +import org.scijava.io3.location.Location; + +/** + * A data handle provides both streaming and random access to the + * bytes at a {@link Location} (a file or a byte array, for example). + *

+ * Handles are discovered with {@link java.util.ServiceLoader}, so a module + * contributing one declares it in its {@code module-info.java}: + *

+ * + *
+ * provides org.scijava.io3.handle.DataHandle with com.example.MyHandle;
+ * 
+ *

+ * or, on the class path, in + * {@code META-INF/services/org.scijava.io3.handle.DataHandle}. + *

+ * + * @author Curtis Rueden + * @see DataHandles#create(Location) + * @see DataHandleInputStream + * @see DataHandleOutputStream + */ +public interface DataHandle extends // + DataInput, DataOutput, Closeable +{ + + /** + * Gets the type of location this handle reads and writes. + *

+ * NB: in SciJava Common this came from {@code Typed#getType}, part of the + * plugin framework. It is declared here so that this component stays + * standalone. + *

+ */ + Class locationType(); + + /** Gets whether this handle can work with the given location. */ + default boolean supports(final Location location) { + return locationType().isInstance(location); + } + + /** + * Sets the location this handle accesses. + * + * @throws IllegalArgumentException if the location is not + * {@link #supports(Location) supported} + */ + void set(L location); + + /** Gets the location this handle accesses. */ + L get(); + + /** + * Gets this handle's priority. Where several handles support the same + * location, the one with the highest priority wins. + *

+ * The value is a plain {@code double} so that this component stays + * standalone; the constants of {@code org.scijava.priority.Priority} are + * compatible with it. + *

+ */ + default double priority() { + return 0; + } + + public enum ByteOrder { + LITTLE_ENDIAN, BIG_ENDIAN + } + + /** Default block size to use when searching through the stream. */ + int DEFAULT_BLOCK_SIZE = 256 * 1024; // 256 KB + + /** Default bound on bytes to search when searching through the stream. */ + int MAX_SEARCH_SIZE = 512 * 1024 * 1024; // 512 MB + + /** Gets whether reading from this handle is supported. */ + boolean isReadable(); + + /** Gets whether writing to this handle is supported. */ + boolean isWritable(); + + /** + * Tests whether this handle's location actually exists at the source. + * + * @return True if the location exists; false if not. + * @throws IOException If something goes wrong with the existence check. + */ + boolean exists() throws IOException; + + /** + * Gets the last modified timestamp of the location. + * + * @return The last modified timestamp, or null if the handle does not support + * this feature or if the location does not exist. + * @throws IOException If something goes wrong with the last modified check. + */ + default Date lastModified() throws IOException { + return null; + } + + /** + * Gets a "fast" checksum which succinctly represents the contents of the data + * stream. The term "fast" here refers to the idea that the checksum be + * retrievable quickly, without actually performing a thorough computation + * across the entire data stream. Typically, such a thing is feasible because + * the checksum was calculated a priori; e.g., artifacts deployed to remote + * Maven repositories are always deployed with corresponding checksum files. + *

+ * No guarantee is made about the exact nature of the checksum (e.g., SHA-1 or + * MD5), only that the value is deterministic for this particular location + * with its current contents. In other words: if a checksum differs from a + * previous inquiry, you can be sure the contents have changed; conversely, if + * the checksum is still the same, the contents are highly likely to be + * unchanged. + *

+ * + * @return The checksum, or null if the handle does not support this feature. + * @throws IOException If something goes wrong when accessing the checksum. + */ + default String checksum() throws IOException { + return null; + } + + /** Returns the current offset in the stream. */ + long offset() throws IOException; + + /** + * Sets the stream offset, measured from the beginning of the stream, at which + * the next read or write occurs. + */ + void seek(long pos) throws IOException; + + /** + * Returns the length of the data in bytes. + * + * @return The length, or -1 if the length is unknown. + */ + long length() throws IOException; + + /** + * Sets the new length of the handle. + * + * @param length New length. + * @throws IOException If there is an error changing the handle's length. + */ + void setLength(long length) throws IOException; + + /** + * Gets the number of bytes which can be read from, or written to, the + * data handle, bounded by the specified number of bytes. + *

+ * In the case of reading, attempting to read the returned number of bytes is + * guaranteed not to throw {@link EOFException}. However, be aware that the + * following methods might still process fewer bytes than indicated + * by this method: + *

+ *
    + *
  • {@link #read(byte[])}
  • + *
  • {@link #read(byte[], int, int)}
  • + *
  • {@link #skip(long)}
  • + *
  • {@link #skipBytes(int)}
  • + *
+ *

+ * In the case of writing, attempting to write the returned number of bytes is + * guaranteed not to expand the length of the handle; i.e., the write will + * only overwrite bytes already within the handle's bounds. + *

+ * + * @param count Desired number of bytes to read/write. + * @return The actual number of bytes which could be read/written, + * which might be less than the requested value. + * @throws IOException If something goes wrong with the check. + */ + default long available(final long count) throws IOException { + final long remain = length() - offset(); + return remain < count ? remain : count; + } + + /** + * Ensures that the handle has sufficient bytes available to read. + * + * @param count Number of bytes to read. + * @see #available(long) + * @throws EOFException If there are insufficient bytes available. + * @throws IOException If the handle is write-only, or something goes wrong + * with the check. + */ + default void ensureReadable(final long count) throws IOException { + if (!isReadable()) throw new IOException("This handle is write-only."); + if (available(count) < count) throw new EOFException(); + } + + /** + * Ensures that the handle has the correct length to be written to, and + * extends it as required. + * + * @param count Number of bytes to write. + * @return {@code true} if the handle's length was sufficient, or + * {@code false} if the handle's length required an extension. + * @throws IOException If the handle is read-only, or something goes wrong + * with the check, or there is an error changing the handle's + * length. + */ + default boolean ensureWritable(final long count) throws IOException { + if (!isWritable()) throw new IOException("This handle is read-only."); + final long minLength = offset() + count; + if (length() < minLength) { + setLength(minLength); + return false; + } + return true; + } + + /** Returns the byte order of the stream. */ + ByteOrder getOrder(); + + /** + * Sets the byte order of the stream. + * + * @param order Order to set. + */ + void setOrder(ByteOrder order); + + /** + * Returns true iff the stream's order is {@link ByteOrder#BIG_ENDIAN}. + * + * @see #getOrder() + */ + default boolean isBigEndian() { + return getOrder() == ByteOrder.BIG_ENDIAN; + } + + /** + * Returns true iff the stream's order is {@link ByteOrder#LITTLE_ENDIAN}. + * + * @see #getOrder() + */ + default boolean isLittleEndian() { + return getOrder() == ByteOrder.LITTLE_ENDIAN; + } + + /** + * Sets the endianness of the stream. + * + * @param little If true, sets the order to {@link ByteOrder#LITTLE_ENDIAN}; + * otherwise, sets the order to {@link ByteOrder#BIG_ENDIAN}. + * @see #setOrder(ByteOrder) + */ + default void setLittleEndian(final boolean little) { + setOrder(little ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN); + } + + /** Gets the native encoding of the stream. */ + String getEncoding(); + + /** Sets the native encoding of the stream. */ + void setEncoding(String encoding); + + /** + * @return a 8 byte long buffer array used for type conversions + */ + byte[] conversionBuffer(); + + /** Reads a string of arbitrary length, terminated by a null char. */ + default String readCString() throws IOException { + final String line = findString("\0"); + return line.length() == 0 ? null : line; + } + + /** Reads a string of up to length n. */ + default String readString(final int n) throws IOException { + final int r = (int) available(n); + final byte[] b = new byte[r]; + readFully(b); + return new String(b, getEncoding()); + } + + /** + * Reads a string ending with one of the characters in the given string. + * + * @see #findString(String...) + */ + default String readString(final String lastChars) throws IOException { + if (lastChars.length() == 1) return findString(lastChars); + final String[] terminators = new String[lastChars.length()]; + for (int i = 0; i < terminators.length; i++) { + terminators[i] = lastChars.substring(i, i + 1); + } + return findString(terminators); + } + + /** + * Reads a string ending with one of the given terminating substrings. + * + * @param terminators The strings for which to search. + * @return The string from the initial position through the end of the + * terminating sequence, or through the end of the stream if no + * terminating sequence is found. + */ + default String findString(final String... terminators) throws IOException { + return findString(true, DEFAULT_BLOCK_SIZE, terminators); + } + + /** + * Reads or skips a string ending with one of the given terminating + * substrings. + * + * @param saveString Whether to collect the string from the current offset to + * the terminating bytes, and return it. If false, returns null. + * @param terminators The strings for which to search. + * @throws IOException If saveString flag is set and the maximum search length + * (512 MB) is exceeded. + * @return The string from the initial position through the end of the + * terminating sequence, or through the end of the stream if no + * terminating sequence is found, or null if saveString flag is unset. + */ + default String findString(final boolean saveString, + final String... terminators) throws IOException + { + return findString(saveString, DEFAULT_BLOCK_SIZE, terminators); + } + + /** + * Reads a string ending with one of the given terminating substrings, using + * the specified block size for buffering. + * + * @param blockSize The block size to use when reading bytes in chunks. + * @param terminators The strings for which to search. + * @return The string from the initial position through the end of the + * terminating sequence, or through the end of the stream if no + * terminating sequence is found. + */ + default String findString(final int blockSize, final String... terminators) + throws IOException + { + return findString(true, blockSize, terminators); + } + + /** + * Reads or skips a string ending with one of the given terminating + * substrings, using the specified block size for buffering. + * + * @param saveString Whether to collect the string from the current offset to + * the terminating bytes, and return it. If false, returns null. + * @param blockSize The block size to use when reading bytes in chunks. + * @param terminators The strings for which to search. + * @throws IOException If saveString flag is set and the maximum search length + * (512 MB) is exceeded. + * @return The string from the initial position through the end of the + * terminating sequence, or through the end of the stream if no + * terminating sequence is found, or null if saveString flag is unset. + */ + default String findString(final boolean saveString, final int blockSize, + final String... terminators) throws IOException + { + final StringBuilder out = new StringBuilder(); + final long startPos = offset(); + long bytesDropped = 0; + final long maxLen = saveString ? MAX_SEARCH_SIZE : Long.MAX_VALUE; + boolean match = false; + int maxTermLen = 0; + for (final String term : terminators) { + final int len = term.length(); + if (len > maxTermLen) maxTermLen = len; + } + + @SuppressWarnings("resource") + final InputStreamReader in = new InputStreamReader( + new DataHandleInputStream<>(this), getEncoding()); + final char[] buf = new char[blockSize]; + long loc = 0; + int r = 0; + + // NB: we need at least 2 bytes to read a char + while (loc < maxLen && ((r = in.read(buf, 0, blockSize)) > 1)) { + // if we're not saving the string, drop any old, unnecessary output + if (!saveString) { + final int outLen = out.length(); + if (outLen >= maxTermLen) { + final int dropIndex = outLen - maxTermLen + 1; + final String last = out.substring(dropIndex, outLen); + out.setLength(0); + out.append(last); + bytesDropped += dropIndex; + } + } + // append block to output + out.append(buf, 0, r); + + // check output, returning smallest possible string + int min = Integer.MAX_VALUE; + int tagLen = 0; + for (final String t : terminators) { + final int len = t.length(); + final int start = (int) (loc - bytesDropped - len); + final int value = out.indexOf(t, start < 0 ? 0 : start); + if (value >= 0 && value < min) { + match = true; + min = value; + tagLen = len; + } + } + + if (match) { + // reset stream to proper location + seek(startPos + bytesDropped + min + tagLen); + + // trim output string + if (saveString) { + out.setLength(min + tagLen); + return out.toString(); + } + return null; + } + + loc += r; + } + + // no match + if (loc > MAX_SEARCH_SIZE) { + throw new IOException("Maximum search length reached."); + } + return saveString ? out.toString() : null; + } + + /** + * Writes the provided string, followed by a newline character. + * + * @param string The string to write. + * @throws IOException If an I/O error occurs. + */ + default void writeLine(final String string) throws IOException { + writeBytes(string); + writeBytes("\n"); + } + + // -- InputStream look-alikes -- + + /** + * Reads the next byte of data from the stream. + * + * @return the next byte of data, or -1 if the end of the stream is reached. + * @throws IOException - if an I/O error occurs. + */ + default int read() throws IOException { + return offset() < length() ? readByte() & 0xff : -1; + } + + /** + * Reads up to b.length bytes of data from the stream into an array of bytes. + * + * @return the total number of bytes read into the buffer. + */ + default int read(final byte[] b) throws IOException { + return read(b, 0, b.length); + } + + /** + * Reads up to {@code len} bytes of data from the stream into an array of + * bytes. + * + * @return the total number of bytes read into the buffer. + */ + int read(byte[] b, int off, int len) throws IOException; + + /** + * Skips over and discards {@code n} bytes of data from the stream. The + * {@code skip} method may, for a variety of reasons, end up skipping over + * some smaller number of bytes, possibly {@code 0}. This may result from any + * of a number of conditions; reaching end of file before {@code n} bytes have + * been skipped is only one possibility. The actual number of bytes skipped is + * returned. If {@code n} is negative, no bytes are skipped. + * + * @param n - the number of bytes to be skipped. + * @return the actual number of bytes skipped. + * @throws IOException - if an I/O error occurs. + */ + default long skip(final long n) throws IOException { + final long skip = available(n); + if (skip <= 0) return 0; + seek(offset() + skip); + return skip; + } + + // -- DataInput methods -- + + @Override + default void readFully(final byte[] b) throws IOException { + readFully(b, 0, b.length); + } + + @Override + default void readFully(final byte[] b, final int off, final int len) + throws IOException + { + // NB: Adapted from java.io.DataInputStream.readFully(byte[], int, int). + if (len < 0) throw new IndexOutOfBoundsException(); + int n = 0; + while (n < len) { + final int count = read(b, off + n, len - n); + if (count < 0) throw new EOFException(); + n += count; + } + } + + @Override + default int skipBytes(final int n) throws IOException { + // NB: Cast here is safe since the value of n bounds the result to an int. + final int skip = (int) available(n); + if (skip < 0) return 0; + seek(offset() + skip); + return skip; + } + + @Override + default boolean readBoolean() throws IOException { + return readByte() != 0; + } + + @Override + default int readUnsignedByte() throws IOException { + return readByte() & 0xff; + } + + @Override + default short readShort() throws IOException { + final byte[] buf = conversionBuffer(); + final int read = read(buf, 0, 2); + if (read < 2) throw new EOFException(); + return Bytes.toShort(buf, isLittleEndian()); + } + + @Override + default int readUnsignedShort() throws IOException { + return readShort() & 0xffff; + } + + @Override + default char readChar() throws IOException { + return (char) readShort(); + } + + @Override + default int readInt() throws IOException { + final byte[] buf = conversionBuffer(); + final int read = read(buf, 0, 4); + if (read < 4) throw new EOFException(); + return Bytes.toInt(buf, isLittleEndian()); + } + + @Override + default long readLong() throws IOException { + final byte[] buf = conversionBuffer(); + final int read = read(buf, 0, 8); + if (read < 8) { + throw new EOFException(); + } + return Bytes.toLong(buf, isLittleEndian()); + } + + @Override + default float readFloat() throws IOException { + return Float.intBitsToFloat(readInt()); + } + + @Override + default double readDouble() throws IOException { + return Double.longBitsToDouble(readLong()); + } + + @Override + default String readLine() throws IOException { + // NB: Adapted from java.io.RandomAccessFile.readLine(). + + final StringBuilder input = new StringBuilder(); + int c = -1; + boolean eol = false; + + while (!eol) { + switch (c = read()) { + case -1: + case '\n': + eol = true; + break; + case '\r': + eol = true; + final long cur = offset(); + if (read() != '\n') seek(cur); + break; + default: + input.append((char) c); + break; + } + } + + if (c == -1 && input.length() == 0) { + return null; + } + return input.toString(); + } + + @Override + default String readUTF() throws IOException { + return DataInputStream.readUTF(this); + } + + // -- DataOutput methods -- + + @Override + default void write(final byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + default void writeBoolean(final boolean v) throws IOException { + write(v ? 1 : 0); + } + + @Override + default void writeByte(final int v) throws IOException { + write(v); + } + + @Override + default void writeShort(final int v) throws IOException { + final byte[] buf = conversionBuffer(); + Bytes.unpack(v, buf, 0, 2, isLittleEndian()); + write(buf, 0, 2); + } + + @Override + default void writeChar(final int v) throws IOException { + writeShort(v); + } + + @Override + default void writeInt(final int v) throws IOException { + final byte[] buf = conversionBuffer(); + Bytes.unpack(v, buf, 0, 4, isLittleEndian()); + write(buf, 0, 4); + } + + @Override + default void writeLong(final long v) throws IOException { + final byte[] buf = conversionBuffer(); + Bytes.unpack(v, buf, 0, 8, isLittleEndian()); + write(buf, 0, 8); + } + + @Override + default void writeFloat(final float v) throws IOException { + final byte[] buf = conversionBuffer(); + Bytes.unpack(Float.floatToIntBits(v), buf, 0, 4, isLittleEndian()); + write(buf, 0, 4); + } + + @Override + default void writeDouble(final double v) throws IOException { + final byte[] buf = conversionBuffer(); + Bytes.unpack(Double.doubleToLongBits(v), buf, 0, 8, isLittleEndian()); + write(buf, 0, 8); + } + + @Override + default void writeBytes(final String s) throws IOException { + write(s.getBytes("UTF-8")); + } + + @Override + default void writeChars(final String s) throws IOException { + final int len = s.length(); + for (int i = 0; i < len; i++) { + writeChar(s.charAt(i)); + } + } + + @Override + default void writeUTF(final String str) throws IOException { + DataHandles.writeUTF(str, this); + } +} diff --git a/io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java b/io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java new file mode 100644 index 000000000..aaa3a6cd6 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java @@ -0,0 +1,119 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; +import java.io.InputStream; + +import org.scijava.io3.location.Location; + +/** + * {@link InputStream} backed by a {@link DataHandle}. + * + * @author Curtis Rueden + * @author Melissa Linkert + */ +public class DataHandleInputStream extends InputStream { + + // -- Fields -- + + private final DataHandle handle; + + private long mark = -1; + + // -- Constructors -- + + /** Creates an input stream around the given {@link DataHandle}. */ + public DataHandleInputStream(final DataHandle handle) { + this.handle = handle; + } + + // -- DataHandleInputStream methods -- + + public DataHandle getDataHandle() { + return handle; + } + + // -- InputStream methods -- + + @Override + public int read() throws IOException { + return handle.read(); + } + + @Override + public int read(final byte[] array, final int offset, final int n) + throws IOException + { + return handle.read(array, offset, n); + } + + @Override + public long skip(final long n) throws IOException { + return handle.skip(n); + } + + @Override + public int available() throws IOException { + long remain = handle.length() - handle.offset(); + if (remain > Integer.MAX_VALUE) remain = Integer.MAX_VALUE; + return (int) remain; + } + + @Override + public synchronized void mark(final int readLimit) { + try { + mark = handle.offset(); + } + catch (final IOException exc) { + throw new IllegalStateException(exc); + } + } + + @Override + public synchronized void reset() throws IOException { + if (mark < 0) throw new IOException("No mark set"); + handle.seek(mark); + } + + @Override + public boolean markSupported() { + return true; + } + + // -- Closeable methods -- + + @Override + public void close() throws IOException { + handle.close(); + mark = -1; + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java b/io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java new file mode 100644 index 000000000..da58984ce --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java @@ -0,0 +1,89 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; +import java.io.OutputStream; + +import org.scijava.io3.location.Location; + +/** + * {@link OutputStream} backed by a {@link DataHandle}. + * + * @author Curtis Rueden + * @author Melissa Linkert + */ +public class DataHandleOutputStream extends OutputStream { + + // -- Fields -- + + private final DataHandle handle; + + // -- Constructor -- + + /** Creates an output stream around the given {@link DataHandle}. */ + public DataHandleOutputStream(final DataHandle handle) { + this.handle = handle; + } + + // -- OutputStream methods -- + + @Override + public void write(final int i) throws IOException { + handle.write(i); + } + + @Override + public void write(final byte[] b) throws IOException { + handle.write(b); + } + + @Override + public void write(final byte[] b, final int off, final int len) + throws IOException + { + handle.write(b, off, len); + } + + // -- Closeable methods -- + + @Override + public void close() throws IOException { + handle.close(); + } + + // -- Flushable methods -- + + @Override + public void flush() throws IOException { + // NB: No action needed. + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/DataHandles.java b/io3/src/main/java/org/scijava/io3/handle/DataHandles.java new file mode 100644 index 000000000..27944b822 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/DataHandles.java @@ -0,0 +1,304 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.DataOutput; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.UTFDataFormatException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.function.LongConsumer; + +import org.scijava.io3.location.Location; + +/** + * Creates {@link DataHandle}s for {@link Location}s, and provides the + * operations that work across any handle. + *

+ * Handles come from {@link ServiceLoader}, so this needs no container and no + * configuration. For an embedded or tested setting where implicit discovery is + * unwanted, construct an instance with an explicit list of handles instead. + *

+ * + * @author Curtis Rueden + * @author Gabriel Einsdorf + */ +public class DataHandles { + + /** Default buffer size for {@link #copy}. */ + public static final int DEFAULT_BUFFER_SIZE = 64 * 1024; + + private static class DefaultHolder { + + static final DataHandles INSTANCE = new DataHandles(discoverHandles()); + } + + private final List> handles; + + /** Creates an instance that uses exactly the given handles. */ + public DataHandles(final List> handles) { + this.handles = new ArrayList<>(handles); + // NB: highest priority first, so the first supporting handle wins. + this.handles.sort(Comparator.comparingDouble( // + (DataHandle h) -> h.priority()).reversed()); + } + + /** Gets the shared instance, backed by {@link ServiceLoader} discovery. */ + public static DataHandles get() { + return DefaultHolder.INSTANCE; + } + + /** + * Creates a handle for the given location. + * + * @param location the location to access + * @return a handle set to that location, or null if no handle supports it + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public DataHandle create(final L location) { + final Optional> prototype = handles.stream() // + .filter(h -> h.supports(location)) // + .findFirst(); + if (prototype.isEmpty()) return null; + // NB: the discovered handles are prototypes; each caller needs its own, + // since a handle carries the position and buffers of one open stream. + final DataHandle handle = instantiate(prototype.get()); + handle.set(location); + return handle; + } + + /** Gets whether the given location exists. */ + public boolean exists(final Location location) throws IOException { + try (final DataHandle handle = create(location)) { + return handle != null && handle.exists(); + } + } + + /** Wraps the given handle so that reads are buffered. */ + public DataHandle readBuffer(final DataHandle handle) { + return handle == null ? null : new ReadBufferDataHandle(handle); + } + + /** Creates a read-buffered handle for the given location. */ + public DataHandle readBuffer(final Location location) { + return readBuffer(create(location)); + } + + /** Wraps the given handle so that writes are buffered. */ + public DataHandle writeBuffer(final DataHandle handle) { + return handle == null ? null : new WriteBufferDataHandle(handle); + } + + /** Creates a write-buffered handle for the given location. */ + public DataHandle writeBuffer(final Location location) { + return writeBuffer(create(location)); + } + + /** Gets the known handles, highest priority first. */ + public List> handles() { + return List.copyOf(handles); + } + + // -- Static utility methods -- + + /** Gets the exception thrown when writing to a read-only handle. */ + protected static IOException readOnlyException() { + return new IOException("This handle is read-only!"); + } + + /** Gets the exception thrown when reading from a write-only handle. */ + protected static IOException writeOnlyException() { + return new IOException("This handle is write-only!"); + } + + /** + * Copies bytes from one handle to another, starting at each handle's + * current offset. + * + * @param in the handle to read from + * @param out the handle to write to + * @return the number of bytes copied + */ + public static long copy(final DataHandle in, + final DataHandle out) throws IOException + { + return copy(in, out, 0, null, DEFAULT_BUFFER_SIZE); + } + + /** + * Copies bytes from one handle to another, starting at each handle's + * current offset. + * + * @param in the handle to read from + * @param out the handle to write to + * @param length how many bytes to copy, or 0 for all of them + * @return the number of bytes copied + */ + public static long copy(final DataHandle in, + final DataHandle out, final long length) throws IOException + { + return copy(in, out, length, null, DEFAULT_BUFFER_SIZE); + } + + /** + * Copies bytes from one handle to another, starting at each handle's + * current offset. + *

+ * NB: SciJava Common reported progress to a {@code Task}, which also + * carried cancellation. Here progress is a plain {@link LongConsumer} of + * the running byte count, so that this component stays standalone; callers + * wanting {@code org.scijava.progress} can adapt it in one lambda, and + * cancellation belongs to the caller's own thread handling. + *

+ * + * @param in the handle to read from + * @param out the handle to write to + * @param length how many bytes to copy, or 0 for all of them + * @param progress notified with the running total, or null for none + * @param bufferSize the size of the copy buffer + * @return the number of bytes copied + * @throws InterruptedIOException if the thread is interrupted, with + * {@link InterruptedIOException#bytesTransferred} set to what had + * been copied + */ + public static long copy(final DataHandle in, + final DataHandle out, final long length, + final LongConsumer progress, final int bufferSize) throws IOException + { + final byte[] buffer = new byte[bufferSize]; + long totalRead = 0; + while (true) { + // NB: cancellation is thread interruption. The check is per block, the + // same granularity as the Task.isCanceled() check this replaces; a read + // already under way cannot be aborted, since plain streams are not + // interruptible. + if (Thread.interrupted()) { + // NB: Thread.interrupted() clears the flag, so restore it: callers + // further up the stack must still see that this thread was cancelled. + Thread.currentThread().interrupt(); + final InterruptedIOException exc = new InterruptedIOException( + "Copy cancelled"); + // NB: int, per the field's type; saturate rather than wrap around. + exc.bytesTransferred = (int) Math.min(totalRead, Integer.MAX_VALUE); + throw exc; + } + final int r; + // NB: do not read past the requested length. + if (length > 0 && totalRead + bufferSize > length) { + r = in.read(buffer, 0, (int) (length - totalRead)); + } + else { + r = in.read(buffer); + } + if (r <= 0) break; // EOF + out.write(buffer, 0, r); + totalRead += r; + if (progress != null) progress.accept(totalRead); + } + return totalRead; + } + + /** + * Writes a string to the specified DataOutput using modified UTF-8 encoding + * in a machine-independent manner. + *

+ * First, two bytes are written to out as if by the {@code writeShort} method + * giving the number of bytes to follow. This value is the number of bytes + * actually written out, not the length of the string. Following the length, + * each character of the string is output, in sequence, using the modified + * UTF-8 encoding for the character. If no exception is thrown, the counter + * {@code written} is incremented by the total number of bytes written to the + * output stream. This will be at least two plus the length of {@code str}, + * and at most two plus thrice the length of {@code str}. + *

+ * + * @param str a string to be written. + * @param out destination to write to + * @return The number of bytes written out. + * @throws IOException if an I/O error occurs. + */ + public static int writeUTF(final String str, final DataOutput out) + throws IOException + { + // Encode string as modified UTF-8 per java.io.DataOutput specification. + final int strlen = str.length(); + int utflen = 0; + for (int i = 0; i < strlen; i++) { + final char c = str.charAt(i); + if (c >= '\u0001' && c <= '\u007F') utflen += 1; + else if (c <= '\u07FF') utflen += 2; + else utflen += 3; + } + if (utflen > 65535) throw new UTFDataFormatException( + "encoded string too long: " + utflen + " bytes"); + final byte[] bytes = new byte[utflen + 2]; + bytes[0] = (byte) ((utflen >>> 8) & 0xFF); + bytes[1] = (byte) (utflen & 0xFF); + int pos = 2; + for (int i = 0; i < strlen; i++) { + final char c = str.charAt(i); + if (c >= '\u0001' && c <= '\u007F') { + bytes[pos++] = (byte) c; + } + else if (c <= '\u07FF') { + bytes[pos++] = (byte) (0xC0 | ((c >> 6) & 0x1F)); + bytes[pos++] = (byte) (0x80 | (c & 0x3F)); + } + else { + bytes[pos++] = (byte) (0xE0 | ((c >> 12) & 0x0F)); + bytes[pos++] = (byte) (0x80 | ((c >> 6) & 0x3F)); + bytes[pos++] = (byte) (0x80 | (c & 0x3F)); + } + } + out.write(bytes); + return utflen + 2; + } + // -- Helper methods -- + + private static DataHandle instantiate(final DataHandle prototype) { + try { + return prototype.getClass().getDeclaredConstructor().newInstance(); + } + catch (final ReflectiveOperationException exc) { + throw new IllegalStateException("Cannot instantiate handle: " + // + prototype.getClass().getName(), exc); + } + } + + private static List> discoverHandles() { + final List> list = new ArrayList<>(); + ServiceLoader.load(DataHandle.class).forEach(list::add); + return list; + } +} diff --git a/io3/src/main/java/org/scijava/io3/handle/DummyHandle.java b/io3/src/main/java/org/scijava/io3/handle/DummyHandle.java new file mode 100644 index 000000000..048d73797 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/DummyHandle.java @@ -0,0 +1,144 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; +import java.util.Arrays; + +import org.scijava.io3.location.DummyLocation; + +/** + * A {@link DataHandle} which reads all zeroes, and writes no actual data. + * + * @author Curtis Rueden + */ +public class DummyHandle extends AbstractDataHandle { + + // -- Fields -- + + private long offset; + private long length; + + // -- Constructors -- + + public DummyHandle() { } + + public DummyHandle(final DummyLocation location) { + set(location); + } + + // -- DataHandle methods -- + + @Override + public boolean isReadable() { + return true; + } + + @Override + public boolean isWritable() { + return true; + } + + @Override + public boolean exists() { + return true; + } + + @Override + public long offset() { + return offset; + } + + @Override + public void seek(final long pos) { + if (pos > length()) setLength(pos); + offset = pos; + } + + @Override + public long length() { + return length; + } + + @Override + public void setLength(final long length) { + this.length = length; + } + + // -- DataInput methods -- + + @Override + public byte readByte() throws IOException { + final long r = available(1); + if (r <= 0) return -1; + offset++; + return 0; + } + + @Override + public int read(final byte[] b, final int off, final int len) + throws IOException + { + final int r = (int) available(len); + offset += r; + Arrays.fill(b, off, off + r, (byte) 0); + return r; + } + + // -- DataOutput methods -- + + @Override + public void write(final int v) throws IOException { + ensureWritable(1); + offset++; + } + + @Override + public void write(final byte[] b, final int off, final int len) + throws IOException + { + ensureWritable(len); + offset += len; + } + + // -- Closeable methods -- + + @Override + public void close() { + // NB: No action needed. + } + + // -- DataHandle methods -- + + @Override + public Class locationType() { + return DummyLocation.class; + } +} diff --git a/io3/src/main/java/org/scijava/io3/handle/FileHandle.java b/io3/src/main/java/org/scijava/io3/handle/FileHandle.java new file mode 100644 index 000000000..5f11dcab3 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/FileHandle.java @@ -0,0 +1,325 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; + +import org.scijava.io3.location.FileLocation; + +/** + * {@link DataHandle} for a {@link FileLocation}. + * + * @author Curtis Rueden + */ +public class FileHandle extends AbstractDataHandle { + + // -- Fields -- + + /** The {@link RandomAccessFile} backing this file handle. */ + private RandomAccessFile raf; + + /** The mode of the {@link RandomAccessFile}. */ + private String mode; + + /** True iff the {@link #close()} has already been called. */ + private boolean closed; + + // -- Constructors -- + + public FileHandle() { } + + public FileHandle(final FileLocation location) { + set(location); + } + + // -- FileHandle methods -- + + /** + * Gets the random access file object backing this FileHandle. If the + * underlying file does not exist yet, it will be created. + */ + public RandomAccessFile getRandomAccessFile() throws IOException { + return writer(); + } + + public String getMode() { + return mode; + } + + public void setMode(final String mode) { + if (raf != null) { + throw new IllegalStateException("File already initialized"); + } + this.mode = mode; + } + + // -- DataHandle methods -- + + @Override + public boolean isReadable() { + return getMode().contains("r"); + } + + @Override + public boolean isWritable() { + return getMode().contains("w"); + } + + @Override + public boolean exists() { + return get().getFile().exists(); + } + + @Override + public Date lastModified() { + final long lastModified = get().getFile().lastModified(); + return lastModified == 0 ? null : new Date(lastModified); + } + + @Override + public long offset() throws IOException { + return exists() ? reader().getFilePointer() : 0; + } + + @Override + public long length() throws IOException { + return exists() ? reader().length() : -1; + } + + @Override + public void setLength(final long length) throws IOException { + writer().setLength(length); + } + + @Override + public int read() throws IOException { + return reader().read(); + } + + @Override + public int read(final byte[] b) throws IOException { + return reader().read(b); + } + + @Override + public int read(final byte[] b, final int off, final int len) + throws IOException + { + return reader().read(b, off, len); + } + + @Override + public void seek(final long pos) throws IOException { + if (isWritable()) { + writer().seek(pos); + } + else { + reader().seek(pos); + } + } + + // -- DataInput methods -- + + @Override + public boolean readBoolean() throws IOException { + return reader().readBoolean(); + } + + @Override + public byte readByte() throws IOException { + return reader().readByte(); + } + + @Override + public void readFully(final byte[] b) throws IOException { + reader().readFully(b); + } + + @Override + public void readFully(final byte[] b, final int off, final int len) + throws IOException + { + reader().readFully(b, off, len); + } + + @Override + public String readLine() throws IOException { + return reader().readLine(); + } + + @Override + public int readUnsignedByte() throws IOException { + return reader().readUnsignedByte(); + } + + @Override + public String readUTF() throws IOException { + return reader().readUTF(); + } + + @Override + public int skipBytes(final int n) throws IOException { + return reader().skipBytes(n); + } + + // -- DataOutput methods -- + + @Override + public void write(final byte[] b) throws IOException { + writer().write(b); + } + + @Override + public void write(final byte[] b, final int off, final int len) + throws IOException + { + writer().write(b, off, len); + } + + @Override + public void write(final int b) throws IOException { + writer().write(b); + } + + @Override + public void writeBoolean(final boolean v) throws IOException { + writer().writeBoolean(v); + } + + @Override + public void writeByte(final int v) throws IOException { + writer().writeByte(v); + } + + @Override + public void writeBytes(final String s) throws IOException { + writer().writeBytes(s); + } + + @Override + public void writeChars(final String s) throws IOException { + writer().writeChars(s); + } + + @Override + public void writeUTF(final String str) throws IOException { + writer().writeUTF(str); + } + + // -- Closeable methods -- + + @Override + public synchronized void close() throws IOException { + if (raf != null) raf.close(); + closed = true; + } + + // -- DataHandle methods -- + + @Override + public void set(FileLocation loc) { + super.set(loc); + + // Infer the initial mode based on file existence + permissions. + final File file = loc.getFile(); + String mode; + if (file.exists()) { + final Path path = loc.getFile().toPath(); + mode = ""; + if (Files.isReadable(path)) mode += "r"; + if (Files.isWritable(path)) mode += "w"; + } + else { + // Non-existent file; assume the intent is to create it. + mode = "rw"; + } + setMode(mode); + } + + // -- DataHandle methods -- + + @Override + public Class locationType() { + return FileLocation.class; + } + + // -- Helper methods -- + + /** + * Access method for the internal {@link RandomAccessFile}, that succeeds + * independently of the underlying file existing on disk. This allows us to + * create a new file for writing. + * + * @return the internal {@link RandomAccessFile} creating a new file on disk + * if needed. + * @throws IOException if the {@link RandomAccessFile} could not be created. + */ + private RandomAccessFile writer() throws IOException { + if (raf == null) initRAF(true); + return raf; + } + + /** + * Access method for the internal {@link RandomAccessFile}, that only succeeds + * if the underlying file exists on disk. This prevents accidental creation of + * an empty file when calling read operations on a non-existent file. + * + * @return the internal {@link RandomAccessFile}. + * @throws IOException if the {@link RandomAccessFile} could not be created, + * or the backing file does not exists. + */ + private RandomAccessFile reader() throws IOException { + if (raf == null) initRAF(false); + return raf; + } + + /** + * Initializes the {@link RandomAccessFile}. + * + * @param create whether to create the {@link RandomAccessFile} if the + * underlying file does not exist yet. + * @throws IOException if the {@link RandomAccessFile} could not be created, + * or the backing file does not exist and the {@code create} + * parameter was set to {@code false}. + */ + private synchronized void initRAF(final boolean create) throws IOException { + if (!create && !exists()) { + throw new IOException("Trying to read from non-existent file!"); + } + if (closed) throw new IOException("Handle already closed"); + if (raf != null) return; + raf = new RandomAccessFile(get().getFile(), getMode()); + } +} diff --git a/io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java b/io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java new file mode 100644 index 000000000..cb6125b0f --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java @@ -0,0 +1,319 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.scijava.io3.location.Location; + +/** + * Read-only buffered {@link DataHandle}. It buffers the underlying handle into + * a fixed number of pages, swapping them out when necessary. + */ +public class ReadBufferDataHandle extends AbstractHigherOrderHandle { + + private static final int DEFAULT_PAGE_SIZE = 10_000; + private static final int DEFAULT_NUM_PAGES = 10; + + private final int pageSize; + private final List pages; + private final int[] slotToPage; + private final LRUReplacementStrategy replacementStrategy; + private final Map pageToSlot; + + /** + * Cached length value, for performance. When reading data, length is not + * expected to change, but querying it (e.g. via native filesystem access) + * can be slow, and we need to query the length frequently. + */ + private long length = -1; + private long offset = 0l; + private byte[] currentPage; + private int currentPageID = -1; + + /** + * Creates a {@link ReadBufferDataHandle} wrapping the provided handle using the + * default values for the size of the pages ({@value #DEFAULT_PAGE_SIZE} byte) + * and number of pages ({@link #DEFAULT_NUM_PAGES}). + * + * @param handle + * the handle to wrap + */ + public ReadBufferDataHandle(final DataHandle handle) { + this(handle, DEFAULT_PAGE_SIZE); + } + + /** + * Creates a {@link ReadBufferDataHandle} wrapping the provided handle using the + * default value for the number of pages ({@link #DEFAULT_NUM_PAGES}). + * + * @param handle + * the handle to wrap + * @param pageSize + * the size of the used pages + */ + public ReadBufferDataHandle(final DataHandle handle, final int pageSize) { + this(handle, pageSize, DEFAULT_NUM_PAGES); + } + + /** + * Creates a {@link ReadBufferDataHandle} wrapping the provided handle. + * + * @param handle + * the handle to wrap + * @param pageSize + * the size of the used pages + * @param numPages + * the number of pages to use + */ + public ReadBufferDataHandle(final DataHandle handle, final int pageSize, final int numPages) { + super(handle); + this.pageSize = pageSize; + + // init maps + slotToPage = new int[numPages]; + Arrays.fill(slotToPage, -1); + + pages = new ArrayList<>(numPages); + for (int i = 0; i < numPages; i++) { + pages.add(null); + } + + pageToSlot = new HashMap<>(); + replacementStrategy = new LRUReplacementStrategy(numPages); + } + + /** + * Ensures that the byte at the given offset is buffered, and sets the current + * page to be the one containing the specified location. + */ + private void ensureBuffered(final long globalOffset) throws IOException { + ensureOpen(); + final int pageID = (int) (globalOffset / pageSize); + if (pageID == currentPageID) + return; + + final int slotID = pageToSlot.computeIfAbsent(pageID, replacementStrategy::pickVictim); + final int inSlotID = slotToPage[slotID]; + + if (inSlotID != pageID) { // desired page is not buffered + // update the mappings + slotToPage[slotID] = pageID; + pageToSlot.put(pageID, slotID); + pageToSlot.put(inSlotID, null); + + // read the page + currentPage = readPage(pageID, slotID); + } else { + currentPage = pages.get(slotID); + } + replacementStrategy.accessed(slotID); + currentPageID = pageID; + } + + /** + * Reads the page with the id pageID into the slot with the id + * slotID. + * + * @param pageID + * the id of the page to read + * @param slotID + * the id of the slot to read the page into + * @return the read page + * @throws IOException + * if the reading fails + */ + private byte[] readPage(final int pageID, final int slotID) throws IOException { + replacementStrategy.accessed(slotID); + byte[] page = pages.get(slotID); + if (page == null) { + // lazy initialization + page = new byte[pageSize]; + pages.set(slotID, page); + } + + final long startOfPage = pageID * (long) pageSize; + if (handle().offset() != startOfPage) { + handle().seek(startOfPage); + } + + // NB: we read repeatedly until the page is full or EOF is reached + // handle().read(..) might read less bytes than requested + int off = 0; + while (off < pageSize) { + final int read = handle().read(page, off, pageSize - off); + if (read == -1) { // EOF + break; + } + off += read; + } + return page; + } + + /** + * Calculates the offset in the current page for the given global offset + */ + private int globalToLocalOffset(final long off) { + return (int) (off % pageSize); + } + + @Override + public void seek(final long pos) throws IOException { + this.offset = pos; + } + + @Override + public long length() throws IOException { + if (length < 0) length = super.length(); + return length; + } + + @Override + public int read(final byte[] b, final int targetOffset, final int len) + throws IOException + { + if (len == 0) return 0; + + // the last position we will read + final long endPos = offset + len; + + // the number of bytes we plan to read + final int readLength = (int) (endPos < length() ? len : length() - offset); + + int read = 0; // the number of bytes we have read + int localTargetOff = targetOffset; + + while (read < readLength) { + ensureBuffered(offset); + + // calculate local offsets + final int pageOffset = globalToLocalOffset(offset); + int localLength = pageSize - pageOffset; + localLength = Math.min(localLength, readLength - read); + localLength = Math.min(localLength, b.length - localTargetOff); + if (localLength == 0) break; // we've read all we can + + // copy the data + System.arraycopy(currentPage, pageOffset, b, localTargetOff, localLength); + + // update offsets + read += localLength; + offset += localLength; + localTargetOff += localLength; + } + // return -1 if we tried to read at least one byte but failed + return read != 0 ? read : -1; + } + + @Override + public byte readByte() throws IOException { + ensureBuffered(offset); + return currentPage[globalToLocalOffset(offset++)]; + } + + @Override + public boolean isReadable() { + return true; + } + + @Override + public long offset() throws IOException { + return offset; + } + + @Override + protected void cleanup() { + pages.clear(); + currentPage = null; + } + + @Override + public void write(final int b) throws IOException { + throw DataHandles.readOnlyException(); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + throw DataHandles.readOnlyException(); + } + + @Override + public void setLength(final long length) throws IOException { + throw DataHandles.readOnlyException(); + } + + /** + * Simple strategy to pick the slot that get's evicted from the cache. This + * strategy always picks the least recently used slot. + */ + private class LRUReplacementStrategy { + + private final Deque queue; + + /** + * Creates a {@link LRUReplacementStrategy} with the specified number of slots. + * + * @param numSlots + * the number of slots to use + */ + public LRUReplacementStrategy(final int numSlots) { + queue = new ArrayDeque<>(numSlots); + + // fill the queue + for (int i = 0; i < numSlots; i++) { + queue.add(i); + } + } + + /** + * Notifies this strategy that a slot has been accessed, pushing it to the end + * of the queue. + * + * @param slotID + * the id of the slot that has been accessed + */ + public void accessed(final int slotID) { + // put accessed element to the end of the queue + queue.remove(slotID); + queue.add(slotID); + } + + public int pickVictim(@SuppressWarnings("unused") final int pageID) { + return queue.peek(); + } + } +} diff --git a/io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java new file mode 100644 index 000000000..580c74952 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java @@ -0,0 +1,70 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.scijava.io3.location.Location; + +/** + * A {@link DataHandle} backed by an {@link InputStream} and/or + * {@link OutputStream}. Supports resetting the handle to the start of the + * internal stream(s). + */ +public interface ResettableStreamHandle extends + StreamHandle +{ + + @Override + default void seek(final long pos) throws IOException { + final long off = offset(); + if (pos == off) return; // nothing to do + if (pos > off) { + // jump from the current offset + jump(pos - off); + } + else { + // jump from the beginning of the stream + resetStream(); + jump(pos); + } + setOffset(pos); + } + + /** + * Resets the stream to its start. + * + * @throws IOException If something goes wrong with the reset + */ + @Override + void resetStream() throws IOException; +} diff --git a/io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java new file mode 100644 index 000000000..9d6f65347 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java @@ -0,0 +1,51 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.scijava.io3.location.Location; + +/** + * A {@link DataHandle} backed by an {@link InputStream} and/or + * {@link OutputStream}. Supports seeking to an arbitrary position within the + * stream. + * + * @author Gabriel Einsdorf + */ +public interface SeekableStreamHandle extends + ResettableStreamHandle +{ + + @Override + void seek(long pos) throws IOException; +} diff --git a/io3/src/main/java/org/scijava/io3/handle/StreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/StreamHandle.java new file mode 100644 index 000000000..99b0b23ce --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/StreamHandle.java @@ -0,0 +1,190 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.scijava.io3.location.Location; + +/** + * A {@link DataHandle} backed by an {@link InputStream} and/or + * {@link OutputStream}. + * + * @author Curtis Rueden + * @author Melissa Linkert + * @author Gabriel Einsdorf + */ +public interface StreamHandle extends DataHandle { + + // -- StreamHandle methods -- + + /** + * Gets an input stream for reading data, positioned at the current offset. + * + * @return the appropriate input stream, or null if the handle is write-only. + * @throws IOException + */ + InputStream in() throws IOException; + + /** + * Gets an output stream for writing data, positioned at the current offset. + * + * @return the appropriate output stream, or null if the handle is read-only. + */ + OutputStream out() throws IOException; + + /** + * Sets the offset of the handle to the given position. + *

+ * This method is intended to be called only in conjunction with reading from + * the input stream, or writing to the output stream. Otherwise, the contents + * may get out of sync. + *

+ */ + void setOffset(long offset); + + /** + * Increments the handle's offset by the given amount. + *

+ * This method is intended to be called only in conjunction with reading from + * the input stream, or writing to the output stream. Otherwise, the contents + * may get out of sync. + *

+ */ + default void advance(final long bytes) throws IOException { + setOffset(offset() + bytes); + } + + // -- DataHandle methods -- + + @Override + default void seek(final long pos) throws IOException { + if (pos == offset()) return; + if (pos > offset()) { + jump(pos - offset()); + } + else { + throw new UnsupportedOperationException( + "Can't seek backwards through this StreamHandle"); + } + } + + /** + * Resets the stream to its start. + * + * @throws IOException If something goes wrong with the reset + */ + void resetStream() throws IOException; + + default void jump(final long n) throws IOException, EOFException { + long remain = n; + while (remain > 0) { + final long r = in().skip(remain); + if (r < 0) throw new EOFException(); + remain -= r; + } + } + + @Override + default void ensureReadable(final long count) throws IOException { + if (in() == null) throw new IOException("This handle is write-only."); + DataHandle.super.ensureReadable(count); + } + + @Override + default boolean ensureWritable(final long count) throws IOException { + if (out() == null) throw new IOException("This handle is read-only."); + return DataHandle.super.ensureWritable(count); + } + + @Override + default int read() throws IOException { + ensureReadable(0); + final int v = in().read(); + if (v >= 0) advance(1); + return v; + } + + @Override + default byte readByte() throws IOException { + int ch = this.read(); + if (ch < 0) throw new EOFException(); + return (byte) (ch); + } + + @Override + default int read(final byte[] b, final int off, final int len) + throws IOException + { + final int n = in().read(b, off, len); + if (n >= 0) advance(n); + return n; + } + + // -- DataOutput methods -- + + @Override + default void write(final int v) throws IOException { + ensureWritable(1); + out().write(v); + advance(1); + } + + @Override + default void writeByte(int v) throws IOException { + write(v); + } + + @Override + default void write(final byte[] b, final int off, final int len) + throws IOException + { + ensureWritable(len); + out().write(b, off, len); + advance(len); + } + + // -- Closeable methods -- + + @Override + default void close() throws IOException { + // TODO: Double check this logic. + try (final InputStream in = in()) { + if (in != null) in.close(); + } + try (final OutputStream out = out()) { + if (out != null) out.close(); + } + } + +} diff --git a/io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java b/io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java new file mode 100644 index 000000000..9c6bc3bde --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java @@ -0,0 +1,203 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import java.io.EOFException; +import java.io.IOException; + +import org.scijava.io3.location.Location; + +/** + * Wraps a {@link DataHandle} and acts as a write buffer. + * + * @author Gabriel Einsdorf + */ +public class WriteBufferDataHandle extends AbstractHigherOrderHandle { + + private static final int DEFAULT_BUFFERSIZE = 10_000; + private long offset = 0; + private int nextPos = 0; + + private byte[] buffer; + private final int bufferSize; + + /** + * Creates a {@link WriteBufferDataHandle} that wraps the given + * {@link DataHandle}, the default size for the buffer is used + * ({@value #DEFAULT_BUFFERSIZE} bytes). + * + * @param handle the handle to wrap + */ + public WriteBufferDataHandle(final DataHandle handle) { + this(handle, DEFAULT_BUFFERSIZE); + } + + /** + * Creates a {@link WriteBufferDataHandle} that wraps the given + * {@link DataHandle} + * + * @param handle the handle to wrap + * @param bufferSize the size of the write buffer in bytes + */ + public WriteBufferDataHandle(final DataHandle handle, + final int bufferSize) + { + super(handle); + this.bufferSize = bufferSize; + } + + @Override + public void write(final int b) throws IOException { + ensureOpen(); + // if buffer is full flush + if (nextPos >= buffer.length) { + flush(); + } + // buffer the byte + buffer[nextPos] = (byte) b; + nextPos++; + offset++; + } + + @Override + public void write(final byte[] b, final int off, final int len) + throws IOException + { + ensureOpen(); + // ensure the range is valid + if ((off < 0) || (off > b.length) || (len < 0) || ((off + + len) > b.length) || ((off + len) < 0)) + { + throw new IndexOutOfBoundsException(); + } + else if (len == 0) { + return; // nothing to do + } + + // skip the buffering and write directly to the handle + if (len > buffer.length) { + flush(); + handle().write(b, off, len); + offset += len; + return; + } + + // copy to buffer / flush if necessary + int start = off; + final int total = off + len; + while (start < total) { + final int numItems = Math.min(buffer.length - nextPos, total - start); + System.arraycopy(b, start, buffer, nextPos, numItems); + start += numItems; + nextPos += numItems; + if (nextPos >= buffer.length) { + flush(); + } + } + } + + /** + * Write the buffer content to the underlying handle + */ + private void flush() throws IOException { + ensureOpen(); + if (nextPos == 0) return; + + handle().write(buffer, 0, nextPos); + nextPos = 0; + } + + @Override + public long length() throws IOException { + // data written out + data in the buffer + return handle().length() + nextPos - 1; + } + + @Override + public void setLength(final long length) throws IOException { + ensureOpen(); + handle().setLength(length); + } + + @Override + public boolean isReadable() { + return false; + } + + /** + * @throws IOException if this handle has been closed + */ + @Override + protected void ensureOpen() throws IOException { + super.ensureOpen(); + if (buffer == null) { + buffer = new byte[bufferSize]; + } + } + + @Override + public long offset() throws IOException { + return offset; + } + + @Override + public void seek(final long pos) throws IOException { + ensureOpen(); + if (pos >= length()) { + throw new EOFException(); + } + flush(); + offset = pos; + handle().seek(offset); + } + + @Override + public long skip(final long n) throws IOException { + throw new IOException("Operation 'skip' is not supported!"); + } + + @Override + public byte readByte() throws IOException { + throw DataHandles.writeOnlyException(); + } + + @Override + public int read(final byte[] b, final int off, final int len) + throws IOException + { + throw DataHandles.writeOnlyException(); + } + + @Override + protected void cleanup() throws IOException { + flush(); + buffer = null; + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/AbstractLocation.java b/io3/src/main/java/org/scijava/io3/location/AbstractLocation.java new file mode 100644 index 000000000..1fb52c2d1 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/AbstractLocation.java @@ -0,0 +1,68 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; +import java.util.Objects; + +/** + * Abstract base class for {@link Location} implementations. + * + * @author Curtis Rueden + */ +public abstract class AbstractLocation implements Location { + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((getURI() == null) ? 0 : getURI().hashCode()); + return result; + } + + @Override + public boolean equals(final Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + final Location other = (Location) obj; + return Objects.equals(getURI(), other.getURI()); + } + + @Override + public String toString() { + final String prefix = getClass().getSimpleName() + ":"; + final URI uri = getURI(); + if (uri != null) return prefix + uri; + final String name = getName(); + if (name != null) return prefix + name; + return prefix + defaultName(); + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java b/io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java new file mode 100644 index 000000000..8362c142c --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java @@ -0,0 +1,57 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +/** + * A {@link LocationResolver} that handles a fixed set of URI schemes. + * + * @author Gabriel Einsdorf + * @author Curtis Rueden + */ +public abstract class AbstractLocationResolver implements LocationResolver { + + private final List schemes; + + protected AbstractLocationResolver(final String... schemes) { + if (schemes.length == 0) { + throw new IllegalArgumentException("At least one scheme is required"); + } + this.schemes = Arrays.asList(schemes); + } + + @Override + public boolean supports(final URI uri) { + return schemes.contains(uri.getScheme()); + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java b/io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java new file mode 100644 index 000000000..92cc7f99e --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java @@ -0,0 +1,55 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +/** + * Abstract base class for {@link RemoteLocation} implementations. + * + * @author Gabriel Einsdorf + */ +public abstract class AbstractRemoteLocation extends AbstractLocation implements + RemoteLocation +{ + + /** + * The {@link RemoteLocation}s timeout in milliseconds. + */ + private long timeout = 15_000l; + + @Override + public long getTimeout() { + return timeout; + } + + @Override + public void setTimeout(final long timeout) { + this.timeout = timeout; + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java b/io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java new file mode 100644 index 000000000..2b1af7190 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java @@ -0,0 +1,100 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +/** + * A {@link Location} that offers methods to browse other locations relative to + * it. + * + * @author Gabriel Einsdorf + * @author Curtis Rueden + */ +public interface BrowsableLocation extends Location { + + /** + * Obtains a location pointing to the parent directory of this one. + * + * @return the parent location of this one, or null if this + * location has no parent. + * @throws IOException if something goes wrong obtaining the parent. + */ + BrowsableLocation parent() throws IOException; + + /** + * Obtains a collection of locations for whom this location is the parent. + * Note that this will only succeed if calls to {@link #isDirectory()} on this + * location return true. + * + * @return A set containing the children of this location, or + * {@link Collections#EMPTY_SET} if this location has no children. + * @throws IOException if something goes wrong obtaining the children. + * @throws IllegalArgumentException if this location is not a directory (i.e., + * {@link #isDirectory()} returns false). + */ + Set children() throws IOException; + + /** + * Obtains a location relative to this one, which will be configured + * like the current location, but point to a the file specified by the + * path parameter. + * + * @param path the relative path of the desired location. + * @return A location that points to the specified file location. + * @throws IOException if something goes wrong obtaining the sibling + */ + BrowsableLocation sibling(String path) throws IOException; + + /** + * Tests whether this location is a directory, meaning that it can have + * children. It is recommended to use this method before calling + * {@link #child(String)} or {@link #children()}, to ensure those calls + * succeed. + * + * @return True iff the location represents a directory. + */ + boolean isDirectory(); + + /** + * Obtains a location with the given name, for whom this location is the + * parent. Note that this will only succeed if calls to {@link #isDirectory()} + * on this location return true. + * + * @param name the name of the child + * @return a location pointing to the child + * @throws IOException if something goes wrong obtaining the child. + * @throws IllegalArgumentException if this location is not a directory (i.e., + * {@link #isDirectory()} returns false). + */ + BrowsableLocation child(String name) throws IOException; +} diff --git a/io3/src/main/java/org/scijava/io3/location/BytesLocation.java b/io3/src/main/java/org/scijava/io3/location/BytesLocation.java new file mode 100644 index 000000000..c89bd8e89 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/BytesLocation.java @@ -0,0 +1,183 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import org.scijava.io3.ByteArrayByteBank; +import org.scijava.io3.ByteBank; +import org.scijava.collections.ByteArray; + +/** + * {@link Location} backed by a {@link ByteBank}. + * + * @author Curtis Rueden + * @author Gabriel Einsdorf + */ +public class BytesLocation extends AbstractLocation { + + private final ByteBank bytes; + + private final String name; + + /** + * Creates a {@link BytesLocation} backed by the specified + * {@link ByteBank}. + * + * @param bytes the {@link ByteBank} that will back this {@link Location} + */ + public BytesLocation(final ByteBank bytes) { + this(bytes, null); + } + + /** + * Creates a {@link BytesLocation} backed by the specified {@link ByteBank}. + * + * @param bytes the {@link ByteBank} that will back this {@link Location} + * @param name the name of this {@link Location} + */ + public BytesLocation(final ByteBank bytes, final String name) { + this.bytes = bytes; + this.name = name; + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} with + * the specified initial capacity, but with a reported size of 0. This method + * can be used to avoid needing to grow the underlying {@link ByteBank}. + */ + public BytesLocation(final int initialCapacity) { + this(initialCapacity, null); + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} with + * the specified initial capacity, but with a reported size of 0. This method + * can be used to avoid needing to grow the underlying {@link ByteBank}. + * + * @param name the name of this {@link Location} + */ + public BytesLocation(final int initialCapacity, final String name) { + this.bytes = new ByteArrayByteBank(initialCapacity); + this.name = name; + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} + * that wraps the specified {@link ByteArray}. + */ + public BytesLocation(final ByteArray bytes) { + this(bytes, null); + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} that + * wraps the specified {@link ByteArray}. + * + * @param name the name of this Location. + */ + public BytesLocation(final ByteArray bytes, final String name) { + this.bytes = new ByteArrayByteBank(bytes); + this.name = name; + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} + * which wraps the specified array. + * + * @param bytes the array to wrap + */ + public BytesLocation(final byte[] bytes) { + this(bytes, null); + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} which + * wraps the specified array. + * + * @param bytes the array to wrap + * @param name the name of this Location. + */ + public BytesLocation(final byte[] bytes, final String name) { + this.bytes = new ByteArrayByteBank(bytes); + this.name = name; + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} with + * the specified initial capacity and the provided data. + * + * @param bytes the bytes to copy into the new {@link BytesLocation} + * @param offset the offset in the bytes array to start copying from + * @param length the number of bytes to copy, starting from the offset + */ + public BytesLocation(final byte[] bytes, final int offset, final int length) { + this(bytes, offset, length, null); + } + + /** + * Creates a {@link BytesLocation} backed by a {@link ByteArrayByteBank} with + * the specified initial capacity and the provided data. + * + * @param bytes the bytes to copy into the new {@link BytesLocation} + * @param offset the offset in the bytes array to start copying from + * @param length the number of bytes to copy, starting from the offset + * @param name the name of this Location. + */ + public BytesLocation(final byte[] bytes, final int offset, final int length, + final String name) + { + this.bytes = new ByteArrayByteBank(length); + this.bytes.setBytes(0l, bytes, offset, length); + this.name = name; + } + + // -- BytesLocation methods -- + + /** Gets the backing {@link ByteBank}. */ + public ByteBank getByteBank() { + return bytes; + } + + @Override + public String getName() { + return name != null ? name : defaultName(); + } + + // -- Object methods -- + + @Override + public int hashCode() { + return System.identityHashCode(this); + } + + @Override + public boolean equals(final Object obj) { + return obj == this; + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/DummyLocation.java b/io3/src/main/java/org/scijava/io3/location/DummyLocation.java new file mode 100644 index 000000000..1b15fc208 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/DummyLocation.java @@ -0,0 +1,39 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +/** + * {@link Location} backed by nothing whatsoever. + * + * @author Curtis Rueden + */ +public class DummyLocation extends AbstractLocation { + // NB: No implementation needed. +} diff --git a/io3/src/main/java/org/scijava/io3/location/FileLocation.java b/io3/src/main/java/org/scijava/io3/location/FileLocation.java new file mode 100644 index 000000000..a0094a0d8 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/FileLocation.java @@ -0,0 +1,127 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +/** + * {@link Location} backed by a {@link File} on disk. + * + * @author Curtis Rueden + * @author Gabriel Einsdorf + */ +public class FileLocation extends AbstractLocation implements + BrowsableLocation +{ + + private final File file; + + public FileLocation(final File file) { + Objects.requireNonNull(file); + this.file = file; + } + + public FileLocation(final String path) { + this(new File(path)); + } + + public FileLocation(final URI path) { + this(new File(path)); + } + + // -- FileLocation methods -- + + /** Gets the associated {@link File}. */ + public File getFile() { + return file; + } + + // -- Location methods -- + + @Override + public URI getURI() { + return getFile().toURI(); + } + + @Override + public String getName() { + return file.getName(); + } + + // -- BrowsableLocation methods -- + + @Override + public FileLocation parent() throws IOException { + return new FileLocation(file.getParentFile()); + } + + @Override + public Set children() throws IOException { + validateDirectory(); + final File[] files = file.listFiles(); + if (files == null) return Collections.emptySet(); + + final Set out = new HashSet<>(files.length); + for (final File child : files) { + out.add(new FileLocation(child)); + } + return out; + } + + @Override + public FileLocation sibling(final String path) { + return new FileLocation(new File(file.getParentFile(), path)); + } + + @Override + public FileLocation child(final String name) { + validateDirectory(); + return new FileLocation(new File(file, name)); + } + + @Override + public boolean isDirectory() { + return file.isDirectory(); + } + + // -- Helper methods -- + + private void validateDirectory() { + if (isDirectory()) return; + throw new IllegalArgumentException( + "This location does not point to a directory!"); + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java b/io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java new file mode 100644 index 000000000..33ab773a9 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java @@ -0,0 +1,49 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; + +/** + * Resolves {@code file:} URIs to a {@link FileLocation}. + * + * @author Gabriel Einsdorf + */ +public class FileLocationResolver extends AbstractLocationResolver { + + public FileLocationResolver() { + super("file"); + } + + @Override + public Location resolve(final URI uri) { + return new FileLocation(uri); + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/Location.java b/io3/src/main/java/org/scijava/io3/location/Location.java new file mode 100644 index 000000000..34de1b793 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/Location.java @@ -0,0 +1,86 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; + + +/** + * A location is a data descriptor, such as a file on disk, a remote + * URL, or a database connection. + *

+ * Analogous to a uniform + * resource identifier ({@link URI}), a location identifies where + * the data resides, without necessarily specifying how to access that + * data. The {@code DataHandle} interface defines a component that knows how to + * read and/or write bytes for a particular kind of location. + *

+ * + * @author Curtis Rueden + * @author Gabriel Einsdorf + */ +public interface Location { + + /** + * Gets the location expressed as a {@link URI}, or null if the location + * cannot be expressed as such. + */ + default URI getURI() { + return null; + } + + /** + * Gets a (typically short) name expressing this location. This string is not + * intended to unambiguously identify the location, but rather act as a + * friendly, human-readable name. The precise behavior will depend on the + * implementation, but as an example, a file-based location could return the + * name of the associated file without its full path. + * + * @return The name, or an empty string if no name is available. + */ + default String getName() { + return defaultName(); + } + + /** + * Gets the default name used when no explicit name is assigned. + *

+ * Note: this is mostly intended for debugging, since most kinds of + * {@code Location} will assign some non-default name. But in cases where that + * does not occur, this value can be useful to detect the situation. + *

+ * + * @return The default name string. + */ + default String defaultName() { + return "Location.defaultName"; + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/LocationResolver.java b/io3/src/main/java/org/scijava/io3/location/LocationResolver.java new file mode 100644 index 000000000..67eedff6e --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/LocationResolver.java @@ -0,0 +1,80 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Turns a {@link URI} into a {@link Location}. + *

+ * Resolvers are discovered with {@link java.util.ServiceLoader}, so a module + * contributing one declares it in its {@code module-info.java}: + *

+ * + *
+ * provides org.scijava.io3.location.LocationResolver with com.example.MyResolver;
+ * 
+ *

+ * or, on the class path, in + * {@code META-INF/services/org.scijava.io3.location.LocationResolver}. + *

+ * + * @author Curtis Rueden + * @author Gabriel Einsdorf + * @see Locations#resolve(URI) + */ +public interface LocationResolver { + + /** Gets whether this resolver can handle the given URI. */ + boolean supports(URI uri); + + /** + * Resolves the given URI to a location. + * + * @param uri the URI to resolve + * @return the resolved location + * @throws URISyntaxException if the URI is malformed for this resolver + */ + Location resolve(URI uri) throws URISyntaxException; + + /** + * Gets this resolver's priority. Where several resolvers support the same + * URI, the one with the highest priority wins. + *

+ * The value is a plain {@code double} so that this component stays + * standalone; the constants of {@code org.scijava.priority.Priority} are + * compatible with it. + *

+ */ + default double priority() { + return 0; + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/Locations.java b/io3/src/main/java/org/scijava/io3/location/Locations.java new file mode 100644 index 000000000..6a28b64fe --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/Locations.java @@ -0,0 +1,127 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; + +/** + * Resolves {@link URI}s to {@link Location}s, using the + * {@link LocationResolver}s available to the runtime. + *

+ * Resolvers come from {@link ServiceLoader}, so this needs no container and no + * configuration: a module that provides a resolver is enough. For an embedded + * or tested setting where implicit discovery is unwanted, construct an + * instance with an explicit list of resolvers instead. + *

+ * + * @author Curtis Rueden + */ +public class Locations { + + private static class DefaultHolder { + + static final Locations INSTANCE = new Locations(discoverResolvers()); + } + + private final List resolvers; + + /** Creates an instance that uses exactly the given resolvers. */ + public Locations(final List resolvers) { + this.resolvers = new ArrayList<>(resolvers); + // NB: highest priority first, so the first supporting resolver wins. + this.resolvers.sort(Comparator.comparingDouble(LocationResolver::priority) + .reversed()); + } + + /** Gets the shared instance, backed by {@link ServiceLoader} discovery. */ + public static Locations get() { + return DefaultHolder.INSTANCE; + } + + /** + * Resolves the given URI string to a location. + *

+ * A string that is not a valid URI is treated as a file path. In general + * file names are not valid URIs - on Windows especially, backslashes are + * not - so this fallback is the common case rather than an edge case. + *

+ * + * @param uriString the URI or file path to resolve + * @return the resolved location + * @throws URISyntaxException if the string resolves to no location + */ + public Location resolve(final String uriString) throws URISyntaxException { + try { + final Location location = resolve(new URI(uriString)); + if (location != null) return location; + } + catch (final URISyntaxException exc) { + // NB: fall through to the file interpretation below. + } + return resolve(new File(uriString).toURI()); + } + + /** + * Resolves the given URI to a location. + * + * @param uri the URI to resolve + * @return the resolved location, or null if no resolver supports the URI + * @throws URISyntaxException if the URI is malformed for its resolver + */ + public Location resolve(URI uri) throws URISyntaxException { + if (uri.getScheme() == null) { + // NB: a scheme-less URI is a local file path. + uri = new File(uri.getPath()).toURI(); + } + final URI resolved = uri; + final Optional resolver = resolvers.stream() // + .filter(r -> r.supports(resolved)) // + .findFirst(); + return resolver.isPresent() ? resolver.get().resolve(uri) : null; + } + + /** Gets the resolvers in use, highest priority first. */ + public List resolvers() { + return List.copyOf(resolvers); + } + + private static List discoverResolvers() { + final List list = new ArrayList<>(); + ServiceLoader.load(LocationResolver.class).forEach(list::add); + return list; + } +} diff --git a/io3/src/main/java/org/scijava/io3/location/RemoteLocation.java b/io3/src/main/java/org/scijava/io3/location/RemoteLocation.java new file mode 100644 index 000000000..1fbdd509e --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/RemoteLocation.java @@ -0,0 +1,53 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +/** + * A {@link Location} which resides on a remote machine. + * + * @author Gabriel Einsdorf + */ +public interface RemoteLocation extends Location { + + /** + * Sets the connection timeout for this location. + * + * @param timeout The timeout in milliseconds. + */ + void setTimeout(long timeout); + + /** + * Gets the connection timeout for this location. + * + * @return The connection timeout in milliseconds. + */ + long getTimeout(); + +} diff --git a/io3/src/main/java/org/scijava/io3/location/URILocation.java b/io3/src/main/java/org/scijava/io3/location/URILocation.java new file mode 100644 index 000000000..c28535772 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/URILocation.java @@ -0,0 +1,114 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link Location} backed by a {@link URI} string. + * + * @author Curtis Rueden + */ +public class URILocation extends AbstractLocation { + + private final URI uri; + + public URILocation(final URI uri) { + this.uri = uri; + } + + public URILocation(final String uriPath) throws URISyntaxException { + this(new URI(uriPath)); + } + + // -- URILocation methods -- + + public Map getQueryMap() { + return decodeQuery(getURI().getRawQuery()); + } + + public String getQueryValue(final String key) { + return getQueryMap().get(key); + } + + // FIXME: look up whether anyone has created a mutatable URI class, + // with individual setters for the various parts. Otherwise, we'll + // have to handle it here! + + // -- Location methods -- + + @Override + public URI getURI() { + return uri; + } + + // -- Helper methods -- + + /** + * Decodes a query string of ampersand-separated key/value pairs. E.g.: + * {@code apples=yummy&bananas=delicious&grapefruits=scrumptious}. + * + * @param query The query string to decode. + * @return A map of the decoded key/value pairs. + */ + private Map decodeQuery(final String query) { + final Map map = new LinkedHashMap<>(); + if (query == null) return map; + for (final String param : query.split("&")) { + final int equals = param.indexOf("="); + if (equals < 0) { + map.put(decode(param), "true"); + } + else { + final String key = decode(param.substring(0, equals)); + final String value = decode(param.substring(equals + 1)); + map.put(key, value); + } + } + return map; + } + + /** + * Decodes a single uuencoded string. + * + * @see URLDecoder + */ + private String decode(final String s) { + // NB: a literal '+' must survive decoding; see + // https://stackoverflow.com/a/6926987 + return URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8); + } + +} diff --git a/io3/src/main/java/org/scijava/io3/location/URLLocation.java b/io3/src/main/java/org/scijava/io3/location/URLLocation.java new file mode 100644 index 000000000..5e4302614 --- /dev/null +++ b/io3/src/main/java/org/scijava/io3/location/URLLocation.java @@ -0,0 +1,73 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +/** + * {@link Location} backed by a {@link URL}. + * + * @author Curtis Rueden + */ +public class URLLocation extends AbstractLocation { + + /** The URL backing this location. */ + private final URL url; + + public URLLocation(final URL url) { + this.url = url; + } + + // -- URLLocation methods -- + + /** Gets the associated {@link URL}. */ + public URL getURL() { + return url; + } + + // -- Location methods -- + + /** + * Gets the associated {@link URI}, or null if this URL is not formatted + * strictly according to RFC2396 and cannot be converted to a URI. + */ + @Override + public URI getURI() { + try { + return getURL().toURI(); + } + catch (final URISyntaxException exc) { + return null; + } + } + +} diff --git a/io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver b/io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver new file mode 100644 index 000000000..4da1bcf87 --- /dev/null +++ b/io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver @@ -0,0 +1 @@ +org.scijava.io3.location.FileLocationResolver diff --git a/io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java b/io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java new file mode 100644 index 000000000..38611932f --- /dev/null +++ b/io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java @@ -0,0 +1,102 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ByteBufferByteBank}, in particular that an off-heap bank stays + * off-heap - the reason to choose it over {@link ByteArrayByteBank}, since + * direct bytes can reach native code and shared memory without a copy. + * + * @author Curtis Rueden + */ +public class ByteBufferByteBankTest { + + @Test + public void testReadWrite() { + final ByteBufferByteBank bank = new ByteBufferByteBank(); + final byte[] data = { 1, 2, 3, 4, 5 }; + bank.setBytes(0, data, 0, data.length); + assertEquals(data.length, bank.size()); + final byte[] read = new byte[data.length]; + bank.getBytes(0, read); + assertArrayEquals(data, read); + } + + @Test + public void testDirectIsOffHeap() { + assertTrue(ByteBufferByteBank.direct().isDirect()); + assertTrue(ByteBufferByteBank.direct(64).isDirect()); + } + + /** + * Growing a direct bank must keep it direct. SciJava Common reallocated + * with ByteBuffer.allocate here, quietly moving the bytes onto the heap. + */ + @Test + public void testDirectStaysDirectWhenGrown() { + final ByteBufferByteBank bank = ByteBufferByteBank.direct(8); + final byte[] data = new byte[1024]; + for (int i = 0; i < data.length; i++) + data[i] = (byte) i; + + // NB: far beyond the initial capacity, so the bank must grow. + bank.setBytes(0, data, 0, data.length); + assertEquals(data.length, bank.size()); + + final byte[] read = new byte[data.length]; + bank.getBytes(0, read); + assertArrayEquals(data, read); + assertTrue(bank.isDirect(), "growing moved the bank back onto the heap"); + } + + @Test + public void testHeapBankIsNotDirect() { + final ByteBufferByteBank bank = new ByteBufferByteBank(8); + bank.setBytes(0, new byte[1024], 0, 1024); + assertTrue(!bank.isDirect()); + } + + @Test + public void testCustomProvider() { + final ByteBufferByteBank bank = new ByteBufferByteBank( + ByteBuffer::allocateDirect, 16); + bank.setBytes(0, new byte[] { 9 }, 0, 1); + assertEquals(9, bank.getByte(0)); + assertTrue(bank.isDirect()); + } +} diff --git a/io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java b/io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java new file mode 100644 index 000000000..d96514f04 --- /dev/null +++ b/io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java @@ -0,0 +1,240 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.handle; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.scijava.io3.location.BytesLocation; +import org.scijava.io3.location.DummyLocation; +import org.scijava.io3.location.FileLocation; +import org.scijava.io3.location.Location; + +/** + * Tests {@link DataHandles}. + * + * @author Curtis Rueden + */ +public class DataHandlesTest { + + /** Handles are found via ServiceLoader, with no container involved. */ + @Test + public void testDiscoveryNeedsNoContainer(@TempDir final Path dir) + throws IOException + { + final File file = dir.resolve("data.txt").toFile(); + Files.write(file.toPath(), "hello".getBytes(StandardCharsets.UTF_8)); + + try (final DataHandle handle = DataHandles.get().create( + new FileLocation(file))) + { + assertInstanceOf(FileHandle.class, handle); + assertEquals(5, handle.length()); + final byte[] bytes = new byte[5]; + handle.read(bytes); + assertArrayEquals("hello".getBytes(StandardCharsets.UTF_8), bytes); + } + } + + /** Each call yields its own handle, since a handle carries stream state. */ + @Test + public void testHandlesAreNotShared(@TempDir final Path dir) + throws IOException + { + final File file = dir.resolve("data.txt").toFile(); + Files.write(file.toPath(), "abcdef".getBytes(StandardCharsets.UTF_8)); + final FileLocation location = new FileLocation(file); + + try (final DataHandle a = DataHandles.get().create(location); + final DataHandle b = DataHandles.get().create(location)) + { + assertNotSame(a, b); + a.seek(3); + // NB: were these the same object, b would be at offset 3 too. + assertEquals(0, b.offset()); + } + } + + /** An unsupported location yields null rather than throwing. */ + @Test + public void testUnsupportedLocation() { + assertNull(DataHandles.get().create(new UnsupportedLocation())); + } + + @Test + public void testExists(@TempDir final Path dir) throws IOException { + final File file = dir.resolve("there.txt").toFile(); + Files.write(file.toPath(), new byte[] { 1, 2, 3 }); + assertTrue(DataHandles.get().exists(new FileLocation(file))); + assertFalse(DataHandles.get().exists(new FileLocation(dir.resolve( + "absent.txt").toFile()))); + } + + /** Bytes locations work in memory, with no file system involved. */ + @Test + public void testBytesHandle() throws IOException { + final BytesLocation location = new BytesLocation(new byte[] { 10, 20, 30 }); + try (final DataHandle handle = DataHandles.get().create( + location)) + { + assertInstanceOf(BytesHandle.class, handle); + assertEquals(3, handle.length()); + assertEquals(10, handle.readByte()); + } + } + + /** Copying reports progress as a running byte count. */ + @Test + @SuppressWarnings("unchecked") + public void testCopyReportsProgress(@TempDir final Path dir) + throws IOException + { + final File in = dir.resolve("in.bin").toFile(); + final byte[] data = new byte[5000]; + for (int i = 0; i < data.length; i++) + data[i] = (byte) i; + Files.write(in.toPath(), data); + final File out = dir.resolve("out.bin").toFile(); + + final List progress = new ArrayList<>(); + try (final DataHandle source = (DataHandle) // + (DataHandle) DataHandles.get().create(new FileLocation(in)); + final DataHandle target = (DataHandle) // + (DataHandle) DataHandles.get().create(new FileLocation(out))) + { + final long copied = DataHandles.copy(source, target, 0, progress::add, + 1024); + assertEquals(data.length, copied); + } + assertArrayEquals(data, Files.readAllBytes(out.toPath())); + assertFalse(progress.isEmpty(), "progress was never reported"); + assertEquals(data.length, progress.get(progress.size() - 1)); + } + + /** + * Cancellation is thread interruption: a copy stops at the next block + * boundary, reports how far it got, and leaves the interrupt flag set for + * whoever is further up the stack. + */ + @Test + @SuppressWarnings("unchecked") + public void testCopyIsCancelledByInterruption(@TempDir final Path dir) + throws Exception + { + final File in = dir.resolve("in.bin").toFile(); + Files.write(in.toPath(), new byte[20000]); + final File out = dir.resolve("out.bin").toFile(); + + final AtomicReference thrown = + new AtomicReference<>(); + final AtomicBoolean flagStillSet = new AtomicBoolean(); + final CountDownLatch started = new CountDownLatch(1); + + final Thread worker = new Thread(() -> { + try (final DataHandle source = (DataHandle) // + (DataHandle) DataHandles.get().create(new FileLocation(in)); + final DataHandle target = (DataHandle) // + (DataHandle) DataHandles.get().create(new FileLocation(out))) + { + started.countDown(); + // NB: one byte at a time, so the copy is still running when the + // interrupt lands. + DataHandles.copy(source, target, 0, bytes -> { + try { + Thread.sleep(1); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + }, 1); + } + catch (final InterruptedIOException exc) { + thrown.set(exc); + flagStillSet.set(Thread.currentThread().isInterrupted()); + } + catch (final IOException exc) { + throw new AssertionError(exc); + } + }); + worker.start(); + assertTrue(started.await(5, TimeUnit.SECONDS)); + Thread.sleep(50); + worker.interrupt(); + worker.join(5000); + + final InterruptedIOException exc = thrown.get(); + assertNotNull(exc, "copy was not cancelled"); + assertTrue(exc.bytesTransferred > 0, "no partial progress reported"); + assertTrue(exc.bytesTransferred < 20000, "copy ran to completion"); + assertTrue(flagStillSet.get(), "the interrupt flag must be restored"); + } + + /** Where two handles support a location, the higher priority one wins. */ + @Test + public void testPriorityOrdering() { + final DataHandle low = new DummyHandle(); + final DataHandle high = new DummyHandle() { + + @Override + public double priority() { + return 100; + } + }; + final List> both = new ArrayList<>(List.of(low, high)); + final DataHandles handles = new DataHandles(both); + assertEquals(high.getClass(), handles.handles().get(0).getClass()); + } + + /** A location type no handle claims. */ + private static class UnsupportedLocation implements Location { + // NB: no implementation needed. + } +} diff --git a/io3/src/test/java/org/scijava/io3/location/LocationsTest.java b/io3/src/test/java/org/scijava/io3/location/LocationsTest.java new file mode 100644 index 000000000..ea7556815 --- /dev/null +++ b/io3/src/test/java/org/scijava/io3/location/LocationsTest.java @@ -0,0 +1,124 @@ +/* + * #%L + * Locations and data handles: a uniform way to address and read bytes. + * %% + * Copyright (C) 2026 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.io3.location; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link Locations}. + * + * @author Curtis Rueden + */ +public class LocationsTest { + + /** Resolvers are found via ServiceLoader, with no container involved. */ + @Test + public void testDiscoveryNeedsNoContainer() throws URISyntaxException { + final Location location = Locations.get().resolve(URI.create( + "file:/tmp/example.txt")); + final FileLocation file = assertInstanceOf(FileLocation.class, location); + assertEquals(new File("/tmp/example.txt"), file.getFile()); + } + + /** An unsupported scheme yields null rather than throwing. */ + @Test + public void testUnsupportedScheme() throws URISyntaxException { + assertNull(Locations.get().resolve(URI.create("nosuchscheme:/whatever"))); + } + + /** A scheme-less URI is interpreted as a local file path. */ + @Test + public void testSchemelessURI() throws URISyntaxException { + final Location location = Locations.get().resolve(URI.create( + "/tmp/example.txt")); + assertInstanceOf(FileLocation.class, location); + } + + /** + * A string that is not a valid URI is treated as a file path. Windows paths + * are the reason this matters: a backslash is not legal in a URI. + */ + @Test + public void testNonURIStringIsAFilePath() throws URISyntaxException { + final Location location = Locations.get().resolve( + "C:\\Users\\example\\data.txt"); + final FileLocation file = assertInstanceOf(FileLocation.class, location); + assertTrue(file.getFile().getPath().contains("data.txt")); + } + + /** An explicit resolver list makes discovery unnecessary. */ + @Test + public void testExplicitResolvers() throws URISyntaxException { + final Locations locations = new Locations(List.of( + new FileLocationResolver())); + assertNotNull(locations.resolve(URI.create("file:/tmp/x"))); + assertNull(locations.resolve(URI.create("bogus:/tmp/x"))); + } + + /** Where two resolvers support a URI, the higher priority one wins. */ + @Test + public void testPriorityOrdering() throws URISyntaxException { + final LocationResolver low = new AbstractLocationResolver("file") { + + @Override + public Location resolve(final URI uri) { + return new DummyLocation(); + } + }; + final LocationResolver high = new AbstractLocationResolver("file") { + + @Override + public Location resolve(final URI uri) { + return new FileLocation(uri); + } + + @Override + public double priority() { + return 100; + } + }; + // NB: given in the "wrong" order, to prove sorting is what decides. + final Locations locations = new Locations(List.of(low, high)); + assertInstanceOf(FileLocation.class, locations.resolve(URI.create( + "file:/tmp/x"))); + assertEquals(high, locations.resolvers().get(0)); + } +} diff --git a/javafx/pom.xml b/javafx/pom.xml new file mode 100644 index 000000000..7c674eb64 --- /dev/null +++ b/javafx/pom.xml @@ -0,0 +1,267 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-javafx + 1.0.0-SNAPSHOT + + SciJava JavaFX + JavaFX widgets, and a dialog to harvest inputs with them. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.javafx + + bsd_2 + SciJava developers. + + + 17.0.21 + + + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-command3 + ${scijava-command3.version} + + + org.scijava + scijava-context + ${scijava-context.version} + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-index + ${scijava-index.version} + + + org.scijava + scijava-ui3 + ${scijava-ui3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-harvest + ${scijava-harvest.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.openjfx + javafx-base + ${javafx-base.version} + + + org.openjfx + javafx-graphics + ${javafx-graphics.version} + + + org.openjfx + javafx-controls + ${javafx-controls.version} + + + org.slf4j + slf4j-api + + + + + + org.scijava + scijava-script3 + ${scijava-script3.version} + test + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + + + org.scijava + scijava-ui3-test + ${scijava-ui3-test.version} + test + + + + org.slf4j + slf4j-simple + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/javafx/src/main/java/module-info.java b/javafx/src/main/java/module-info.java new file mode 100644 index 000000000..29a8180ed --- /dev/null +++ b/javafx/src/main/java/module-info.java @@ -0,0 +1,48 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.javafx { + + exports org.scijava.javafx; + + // NB: one opens, to the container alone, so that it can inject and + // construct these plugins. `opens` is not `exports`. + opens org.scijava.javafx to org.scijava.context; + + requires transitive javafx.base; + requires transitive javafx.controls; + requires transitive javafx.graphics; + requires transitive org.scijava.ui3; + requires transitive org.scijava.command3; + requires transitive org.scijava.context; + requires org.scijava.discovery; + requires org.scijava.priority; + requires org.slf4j; + +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxChoice.java b/javafx/src/main/java/org/scijava/javafx/FxChoice.java new file mode 100644 index 000000000..4848bb0d8 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxChoice.java @@ -0,0 +1,116 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.List; +import java.util.Objects; + +import javafx.collections.FXCollections; +import javafx.scene.Node; +import javafx.scene.control.ComboBox; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; + +/** + * A widget for a parameter with a known set of values, declared or computed. + * + * @author Curtis Rueden + */ +public class FxChoice extends FxWidget { + + private final ComboBox comboBox = new ComboBox<>(); + + public FxChoice(final ParameterNode node, final ParameterModel model) { + super(node, model); + comboBox.setMaxWidth(Double.MAX_VALUE); + refresh(); + comboBox.valueProperty().addListener((obs, old, value) -> { + if (value != null) update(value); + }); + } + + @Override + public Node control() { + return comboBox; + } + + @Override + protected void doRefresh() { + final List choices = node().choices(); + if (!choices.equals(comboBox.getItems())) { + comboBox.setItems(FXCollections.observableArrayList(choices)); + } + final Object value = value(); + final Object selected = value == null ? null : asChoice(value, choices); + if (!Objects.equals(comboBox.getValue(), selected)) { + comboBox.setValue(selected); + } + } + + // -- Helper methods -- + + /** Finds the choice a value stands for, declared choices being strings. */ + private Object asChoice(final Object value, final List choices) { + for (final Object choice : choices) { + if (Objects.equals(choice, value)) return choice; + } + for (final Object choice : choices) { + if (String.valueOf(choice).equals(String.valueOf(value))) return choice; + } + return null; + } + + /** Makes {@link FxChoice}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements FxWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return !node.choices().isEmpty(); + } + + @Override + public FxWidget create(final ParameterNode node, final ParameterModel model, + final WidgetPanelFactory panels) + { + return new FxChoice(node, model); + } + + @Override + public double priority() { + return Priority.HIGH; + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxDialog.java b/javafx/src/main/java/org/scijava/javafx/FxDialog.java new file mode 100644 index 000000000..9fc4797cd --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxDialog.java @@ -0,0 +1,187 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.List; +import java.util.Map; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; + +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanels; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The dialog a user fills in, in JavaFX. + * + * @author Curtis Rueden + */ +public class FxDialog { + + private static final Logger log = LoggerFactory.getLogger(FxDialog.class); + + private final ParameterModel model; + private final WidgetPanels builder; + private final Stage stage; + private final BorderPane content = new BorderPane(); + private final ScrollPane scroll = new ScrollPane(); + private final Button okButton = new Button("OK"); + private final Label problem = new Label(); + + private FxPanel panel; + private boolean accepted; + private boolean rebuilding; + private boolean sized; + + public FxDialog(final Window owner, final String title, + final ParameterModel model, final List> factories) + { + this.model = model; + builder = new WidgetPanels<>(factories, new FxPanel.Factory(model)); + + stage = new Stage(); + stage.setTitle(title); + stage.initModality(Modality.APPLICATION_MODAL); + if (owner != null) stage.initOwner(owner); + + problem.setStyle("-fx-text-fill: darkred;"); + okButton.setDefaultButton(true); + okButton.setOnAction(e -> { + accepted = true; + stage.close(); + }); + final Button cancelButton = new Button("Cancel"); + cancelButton.setCancelButton(true); + cancelButton.setOnAction(e -> stage.close()); + + final HBox buttons = new HBox(6, okButton, cancelButton); + buttons.setAlignment(Pos.CENTER_RIGHT); + final BorderPane south = new BorderPane(); + south.setCenter(problem); + BorderPane.setAlignment(problem, Pos.CENTER_LEFT); + south.setRight(buttons); + south.setPadding(new Insets(8)); + + scroll.setFitToWidth(true); + scroll.setStyle("-fx-background-color: transparent;"); + content.setCenter(scroll); + content.setBottom(south); + content.setPadding(new Insets(8, 8, 0, 8)); + HBox.setHgrow(content, Priority.ALWAYS); + + model.onTreeChanged(tree -> Platform.runLater(() -> { + log.debug("Parameter tree changed; rebuilding widgets"); + rebuild(); + })); + model.values().onChange(change -> Platform.runLater(this::refresh)); + + rebuild(); + stage.setScene(new Scene(content)); + } + + /** + * Shows the dialog and waits for the user. + * + * @return true if the user accepted, false if they dismissed it + */ + public boolean showDialog() { + stage.showAndWait(); + return accepted; + } + + /** Gets the window itself, to position or decorate it. */ + public Stage stage() { + return stage; + } + + // -- Helper methods -- + + private void rebuild() { + if (rebuilding) return; + rebuilding = true; + try { + panel = (FxPanel) builder.build(model); + log.debug("Built {} widget(s) for {}", panel.widgets().size(), stage + .getTitle()); + if (panel.widgets().isEmpty()) { + log.warn("No widgets for {}: either it declares no inputs, or no " + + "widget factory accepted them", stage.getTitle()); + } + scroll.setContent(panel.control()); + validate(); + // NB: as in Swing - the dialog is sized once and may then grow for + // parameters that appear, but never moves and never shrinks. + if (!sized) { + stage.sizeToScene(); + stage.centerOnScreen(); + sized = true; + } + else { + final double width = Math.max(stage.getWidth(), content.prefWidth(-1)); + final double height = Math.max(stage.getHeight(), content.prefHeight( + width)); + stage.setWidth(width); + stage.setHeight(height); + } + } + finally { + rebuilding = false; + } + } + + private void refresh() { + if (rebuilding || panel == null) return; + panel.refresh(); + validate(); + } + + /** Shows what is wrong, and lets the user proceed only when nothing is. */ + private void validate() { + final Map problems = model.problems(); + panel.showProblems(problems); + okButton.setDisable(!problems.isEmpty()); + problem.setText(problems.isEmpty() ? "" : problems.values().iterator() + .next()); + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxFile.java b/javafx/src/main/java/org/scijava/javafx/FxFile.java new file mode 100644 index 000000000..f57412714 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxFile.java @@ -0,0 +1,115 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.io.File; + +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for a file or directory: a path to type, and a button to browse. + * + * @author Curtis Rueden + */ +public class FxFile extends FxWidget { + + private final TextField path = new TextField(); + private final HBox box; + + public FxFile(final ParameterNode node, final ParameterModel model) { + super(node, model); + final Button browse = new Button("Browse..."); + browse.setOnAction(e -> browse()); + box = new HBox(4, path, browse); + HBox.setHgrow(path, Priority.ALWAYS); + refresh(); + path.textProperty().addListener((obs, old, text) -> update(text.isEmpty() + ? null : new File(text))); + } + + @Override + public Node control() { + return box; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final String text = value == null ? "" : value.toString(); + if (!text.equals(path.getText())) path.setText(text); + } + + // -- Helper methods -- + + private void browse() { + final File chosen; + if (Widgets.isStyle(node(), "directory")) { + final DirectoryChooser chooser = new DirectoryChooser(); + chosen = chooser.showDialog(box.getScene().getWindow()); + } + else { + final FileChooser chooser = new FileChooser(); + chosen = Widgets.isStyle(node(), "save") // + ? chooser.showSaveDialog(box.getScene().getWindow()) // + : chooser.showOpenDialog(box.getScene().getWindow()); + } + if (chosen != null) path.setText(chosen.getPath()); + } + + /** Makes {@link FxFile}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements FxWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return node.choices().isEmpty() && Widgets.type(node) == File.class; + } + + @Override + public FxWidget create(final ParameterNode node, final ParameterModel model, + final WidgetPanelFactory panels) + { + return new FxFile(node, model); + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java b/javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java new file mode 100644 index 000000000..966b0c383 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java @@ -0,0 +1,119 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.ArrayList; +import java.util.List; + +import org.scijava.context.Context; +import org.scijava.context.Dependency; +import org.scijava.context.Plugin; +import org.scijava.discovery.Discovery; +import org.scijava.execute.Preprocessor; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.InputHarvester; +import org.scijava.ui3.WidgetFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Asks the user for a run's remaining inputs, in a JavaFX dialog. + * + * @author Curtis Rueden + */ +@Plugin(type = Preprocessor.class) +public class FxInputHarvester extends InputHarvester { + + private static final Logger log = LoggerFactory.getLogger( + FxInputHarvester.class); + + @Dependency(required = false) + private Context context; + + private List> factories; + + private String title = "Inputs"; + + /** Creates a harvester whose widgets come from the container. */ + public FxInputHarvester() { + // NB: the container injects the context and discovers the widgets. + } + + /** Creates a harvester with the given widgets, and no container. */ + public FxInputHarvester(final List> factories) { + this.factories = List.copyOf(factories); + } + + /** Creates a harvester whose widgets come from the given container. */ + public static FxInputHarvester of(final Context context) { + final FxInputHarvester harvester = new FxInputHarvester(); + harvester.context = context; + return harvester; + } + + /** Sets the title the dialog shows. */ + public void setTitle(final String title) { + this.title = title; + } + + @Override + public boolean harvest(final ParameterModel model) { + // NB: building and showing are one hop, unlike Swing's two: showAndWait + // runs a nested event loop, so the dialog must be built and shown by the + // same visit to the JavaFX thread. + return FxThread.get(() -> new FxDialog(null, title, model, factories()) + .showDialog()); + } + + // -- Helper methods -- + + /** Gets the widgets available, discovering them on first use. */ + private synchronized List> factories() { + if (factories != null) return factories; + final List> found = new ArrayList<>(); + if (context != null) { + for (final Discovery discovery : context.plugins( + WidgetFactory.class)) + { + final WidgetFactory factory = discovery.get(); + if (!FxWidget.class.isAssignableFrom(factory.widgetType())) continue; + @SuppressWarnings("unchecked") + final WidgetFactory fx = (WidgetFactory) factory; + found.add(fx); + } + } + if (found.isEmpty()) { + log.warn("No JavaFX widget factories discovered. Is the annotation " + + "index (META-INF/json/org.scijava.context.Plugin) on the classpath?"); + } + factories = List.copyOf(found); + return factories; + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxMenus.java b/javafx/src/main/java/org/scijava/javafx/FxMenus.java new file mode 100644 index 000000000..bd81f7fc5 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxMenus.java @@ -0,0 +1,147 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCharacterCombination; +import javafx.scene.input.KeyCombination; +import javafx.scene.input.KeyCombination.Modifier; + +import org.scijava.command3.Accelerator; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuCreator; +import org.scijava.command3.MenuTree; +import org.scijava.command3.Menus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Builds a JavaFX menu bar from a {@link MenuTree}. + * + * @author Curtis Rueden + */ +public class FxMenus implements MenuCreator { + + private static final Logger log = LoggerFactory.getLogger(FxMenus.class); + + private final Consumer onSelect; + + public FxMenus(final Consumer onSelect) { + this.onSelect = onSelect; + } + + /** Builds a menu bar holding the given menu tree. */ + public static MenuBar create(final MenuTree root, + final Consumer onSelect) + { + return Menus.build(root, new MenuBar(), new FxMenus(onSelect)); + } + + @Override + public Menu topMenu(final MenuTree node, final MenuBar bar) { + final Menu menu = new Menu(node.label()); + bar.getMenus().add(menu); + return menu; + } + + @Override + public Menu subMenu(final MenuTree node, final Menu parent) { + final Menu menu = new Menu(node.label()); + parent.getItems().add(menu); + return menu; + } + + @Override + public void item(final MenuTree leaf, final Menu parent) { + final CommandInfo command = leaf.entry().orElseThrow(); + final MenuItem item = new MenuItem(leaf.label()); + command.accelerator().map(FxMenus::accelerator).ifPresent( + item::setAccelerator); + command.iconPath().map(this::icon).ifPresent(item::setGraphic); + item.setOnAction(e -> onSelect.accept(command)); + parent.getItems().add(item); + } + + // -- Helper methods -- + + /** + * Turns an accelerator into a JavaFX key combination. + *

+ * One with no modifiers is handed to + * {@link KeyCombination#keyCombination(String)}, so JavaFX's own notation + * ({@code "Ctrl+Shift+N"}) works too. + *

+ * + * @return the combination, or null if it cannot be read + */ + static KeyCombination accelerator(final String accelerator) { + final Optional parsed = Accelerator.parse(accelerator); + if (parsed.isEmpty()) return null; + final Accelerator a = parsed.get(); + if (!a.hasModifiers()) { + try { + return KeyCombination.keyCombination(a.key()); + } + catch (final IllegalArgumentException exc) { + log.warn("Unreadable accelerator: {}", accelerator); + return null; + } + } + if (!a.isCharacter()) { + log.warn("Unreadable accelerator: {}", accelerator); + return null; + } + final List modifiers = new ArrayList<>(); + // NB: SHORTCUT_DOWN is JavaFX saying what Swing needs a Toolkit call for. + if (a.isShortcut()) modifiers.add(KeyCombination.SHORTCUT_DOWN); + if (a.isAlt()) modifiers.add(KeyCombination.ALT_DOWN); + if (a.isShift()) modifiers.add(KeyCombination.SHIFT_DOWN); + return new KeyCharacterCombination(a.key(), modifiers.toArray( + new Modifier[0])); + } + + private ImageView icon(final String path) { + final java.net.URL url = getClass().getResource(path); + if (url == null) { + log.warn("No such icon: {}", path); + return null; + } + return new ImageView(new Image(url.toExternalForm())); + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxMessage.java b/javafx/src/main/java/org/scijava/javafx/FxMessage.java new file mode 100644 index 000000000..7fc67f46d --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxMessage.java @@ -0,0 +1,95 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import javafx.scene.Node; +import javafx.scene.control.Label; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget that shows text rather than collecting it. + * + * @author Curtis Rueden + */ +public class FxMessage extends FxWidget { + + private final Label label = new Label(); + + public FxMessage(final ParameterNode node, final ParameterModel model) { + super(node, model); + label.setWrapText(true); + refresh(); + } + + @Override + public Node control() { + return label; + } + + @Override + public boolean isLabeled() { + return false; // NB: the message is the whole row. + } + + @Override + protected void doRefresh() { + final Object value = value(); + label.setText(value == null ? "" : value.toString()); + } + + /** Makes {@link FxMessage}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements FxWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return Widgets.isStyle(node, "message"); + } + + @Override + public FxWidget create(final ParameterNode node, final ParameterModel model, + final WidgetPanelFactory panels) + { + return new FxMessage(node, model); + } + + @Override + public double priority() { + return Priority.VERY_HIGH; + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxNumber.java b/javafx/src/main/java/org/scijava/javafx/FxNumber.java new file mode 100644 index 000000000..09d647a37 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxNumber.java @@ -0,0 +1,230 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import javafx.scene.Node; +import javafx.scene.control.ScrollBar; +import javafx.scene.control.Slider; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for a number: a spinner, optionally beside a slider or scroll bar. + *

+ * NB: JavaFX has no equivalent of {@code SpinnerNumberModel}'s type + * preservation - its factories are {@code Integer} or {@code Double} and + * nothing else - so the widget converts on the way out. This is the kind of + * difference a second binding is for: the same parameter metadata, a different + * amount of work to honor it. + *

+ * + * @author Curtis Rueden + */ +public class FxNumber extends FxWidget { + + private final Class type; + private final Number min; + private final Number max; + private final Number softMin; + private final Number softMax; + private final Number step; + + private final Spinner spinner; + private final Slider slider; + private final ScrollBar scrollBar; + private final HBox box; + + private boolean syncing; + + public FxNumber(final ParameterNode node, final ParameterModel model) { + super(node, model); + type = Widgets.box(Widgets.type(node)); + min = Widgets.min(node); + max = Widgets.max(node); + softMin = Widgets.softMin(node); + softMax = Widgets.softMax(node); + step = Widgets.stepSize(node); + + spinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory( // + min == null ? -Double.MAX_VALUE : min.doubleValue(), // + max == null ? Double.MAX_VALUE : max.doubleValue(), // + number(value()).doubleValue(), step.doubleValue())); + spinner.setEditable(true); + spinner.setPrefWidth(110); + + final boolean scalable = softMin != null && softMax != null; + slider = scalable && Widgets.isStyle(node, "slider") ? slider() : null; + scrollBar = slider == null && scalable && Widgets.isStyle(node, + "scroll bar") ? scrollBar() : null; + final Node scale = slider != null ? slider : scrollBar; + + box = new HBox(4); + if (scale != null) { + box.getChildren().add(scale); + HBox.setHgrow(scale, Priority.ALWAYS); + } + box.getChildren().add(spinner); + + refresh(); + spinner.valueProperty().addListener((obs, old, value) -> { + syncScale(); + update(number(value)); + }); + // NB: an editable JavaFX spinner does not commit what was typed unless + // the user presses Enter, so a value typed and then tabbed away from + // would be silently lost. + spinner.getEditor().textProperty().addListener((obs, old, text) -> { + final Number typed = Widgets.toType(text, Double.class); + if (typed != null) update(number(typed)); + }); + if (slider != null) slider.valueProperty().addListener((obs, old, pos) -> { + if (!syncing) spinner.getValueFactory().setValue(fromScale(pos + .doubleValue())); + }); + if (scrollBar != null) scrollBar.valueProperty().addListener((obs, old, + pos) -> { + if (!syncing) spinner.getValueFactory().setValue(fromScale(pos + .doubleValue())); + }); + } + + @Override + public Node control() { + return box; + } + + @Override + protected void doRefresh() { + final double value = number(value()).doubleValue(); + if (spinner.getValue() == null || spinner.getValue() != value) { + spinner.getValueFactory().setValue(value); + } + syncScale(); + } + + // -- Helper methods -- + + private Slider slider() { + final Slider s = new Slider(softMin.doubleValue(), softMax.doubleValue(), + number(value()).doubleValue()); + s.setPrefWidth(160); + Widgets.styleValue(node(), "ticks").ifPresent(count -> { + try { + final int ticks = Math.max(1, Integer.parseInt(count)); + s.setMajorTickUnit((softMax.doubleValue() - softMin.doubleValue()) / + ticks); + s.setMinorTickCount(0); + s.setShowTickMarks(true); + s.setShowTickLabels(true); + } + catch (final NumberFormatException exc) { + // NB: an unreadable hint is no reason to lose the slider. + } + }); + return s; + } + + private ScrollBar scrollBar() { + final ScrollBar s = new ScrollBar(); + s.setMin(softMin.doubleValue()); + s.setMax(softMax.doubleValue()); + s.setUnitIncrement(step.doubleValue()); + s.setPrefWidth(160); + return s; + } + + private void syncScale() { + if (slider == null && scrollBar == null) return; + syncing = true; + try { + final double value = spinner.getValue() == null ? 0 : spinner.getValue(); + final double clamped = Math.max(softMin.doubleValue(), Math.min(softMax + .doubleValue(), value)); + if (slider != null) slider.setValue(clamped); + if (scrollBar != null) scrollBar.setValue(clamped); + } + finally { + syncing = false; + } + } + + /** Snaps a position on the scale to a whole number of steps. */ + private double fromScale(final double pos) { + final double steps = Math.round((pos - softMin.doubleValue()) / step + .doubleValue()); + return softMin.doubleValue() + steps * step.doubleValue(); + } + + /** Reads a value as the parameter's own numeric type. */ + private Number number(final Object value) { + if (value == null) return min != null ? number(min) : (Number) Widgets + .toType("0", type); + final Number n = value instanceof Number ? (Number) value // + : Widgets.toType(String.valueOf(value), type); + if (n == null) return (Number) Widgets.toType("0", type); + if (type == Byte.class) return n.byteValue(); + if (type == Short.class) return n.shortValue(); + if (type == Integer.class) return n.intValue(); + if (type == Long.class) return n.longValue(); + if (type == Float.class) return n.floatValue(); + return n.doubleValue(); + } + + /** Makes {@link FxNumber}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements FxWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + if (!node.choices().isEmpty() || !Widgets.isNumber(node)) return false; + final Class type = Widgets.box(Widgets.type(node)); + return type != BigInteger.class && type != BigDecimal.class; + } + + @Override + public FxWidget create(final ParameterNode node, final ParameterModel model, + final WidgetPanelFactory panels) + { + return new FxNumber(node, model); + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxPanel.java b/javafx/src/main/java/org/scijava/javafx/FxPanel.java new file mode 100644 index 000000000..3355f99f2 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxPanel.java @@ -0,0 +1,165 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.TitledPane; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetPanel; +import org.scijava.ui3.WidgetPanelFactory; + +/** + * A panel of JavaFX widgets: the body of a dialog, or one group within it. + *

+ * A collapsible group is a {@link TitledPane}, which JavaFX gives for free and + * Swing has to be talked into. + *

+ * + * @author Curtis Rueden + */ +public class FxPanel extends FxWidget implements WidgetPanel { + + private final List widgets; + private final Map labels = new LinkedHashMap<>(); + private final GridPane body = new GridPane(); + private final Node control; + + public FxPanel(final ParameterNode group, final List widgets, + final ParameterModel model) + { + super(group, model); + this.widgets = new ArrayList<>(widgets); + + body.setHgap(6); + body.setVgap(4); + body.setPadding(new Insets(4)); + final ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setHalignment(HPos.RIGHT); + final ColumnConstraints widgetColumn = new ColumnConstraints(); + widgetColumn.setHgrow(Priority.ALWAYS); + widgetColumn.setFillWidth(true); + body.getColumnConstraints().addAll(labelColumn, widgetColumn); + layOut(); + + if (group == null) { + control = body; + } + else { + final TitledPane pane = new TitledPane(group.label(), body); + pane.setCollapsible(group.isCollapsible()); + pane.setExpanded(!group.isCollapsed()); + control = pane; + } + } + + @Override + public List widgets() { + return List.copyOf(widgets); + } + + @Override + public Node control() { + return control; + } + + @Override + public boolean isLabeled() { + return false; // NB: a group carries its own title. + } + + @Override + public void showProblems(final Map problems) { + labels.forEach((key, label) -> { + final String problem = problems.get(key); + label.setStyle(problem == null ? "" : "-fx-text-fill: darkred;"); + label.setTooltip(problem == null ? null : new Tooltip(problem)); + }); + WidgetPanel.super.showProblems(problems); + } + + @Override + protected void doRefresh() { + widgets.forEach(FxWidget::refresh); + } + + // -- Helper methods -- + + private void layOut() { + int row = 0; + for (final FxWidget widget : widgets) { + if (widget.isLabeled()) { + final ParameterNode node = widget.node(); + final Label label = new Label(node.label()); + node.member().map(m -> m.member().description()) // + .filter(d -> d != null && !d.isEmpty()) // + .ifPresent(d -> label.setTooltip(new Tooltip(d))); + node.member().ifPresent(m -> labels.put(m.member().key(), label)); + body.add(label, 0, row); + body.add(widget.control(), 1, row); + } + else { + // NB: an unlabeled widget - a group, a message - takes the row. + body.add(widget.control(), 0, row, 2, 1); + } + row++; + } + } + + /** Makes {@link FxPanel}s. */ + public static class Factory implements WidgetPanelFactory { + + private final ParameterModel model; + + public Factory(final ParameterModel model) { + this.model = model; + } + + @Override + public FxWidget create(final ParameterNode group, + final List widgets) + { + return new FxPanel(group, widgets, model); + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxText.java b/javafx/src/main/java/org/scijava/javafx/FxText.java new file mode 100644 index 000000000..f1393e298 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxText.java @@ -0,0 +1,125 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import javafx.scene.Node; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputControl; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for text: a field, a password field, or a multi-line area. Also the + * arbitrary-precision numbers, which no spinner can step. + * + * @author Curtis Rueden + */ +public class FxText extends FxWidget { + + private final TextInputControl text; + + public FxText(final ParameterNode node, final ParameterModel model) { + super(node, model); + if (Widgets.isStyle(node, "text area")) { + final TextArea area = new TextArea(); + area.setPrefRowCount(4); + area.setWrapText(true); + text = area; + } + else if (Widgets.isStyle(node, "password")) text = new PasswordField(); + else text = new TextField(); + refresh(); + text.textProperty().addListener((obs, old, value) -> changed(value)); + } + + @Override + public Node control() { + return text; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final String s = value == null ? "" : value.toString(); + if (!s.equals(text.getText())) text.setText(s); + } + + // -- Helper methods -- + + private void changed(final String s) { + final Class type = Widgets.box(Widgets.type(node())); + if (type == String.class) update(s); + else if (type == Character.class) { + if (!s.isEmpty()) update(s.charAt(0)); + } + else { + // NB: half-typed text is not a value, so an unparseable field leaves + // the parameter alone rather than writing a zero into it. + final Number value = Widgets.toType(s, type); + if (value != null) update(value); + } + } + + /** Makes {@link FxText}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements FxWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + if (!node.choices().isEmpty()) return false; + final Class type = Widgets.box(Widgets.type(node)); + return Widgets.isText(node) || type == BigInteger.class || + type == BigDecimal.class; + } + + @Override + public FxWidget create(final ParameterNode node, final ParameterModel model, + final WidgetPanelFactory panels) + { + return new FxText(node, model); + } + + @Override + public double priority() { + return Priority.LOW; + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxThread.java b/javafx/src/main/java/org/scijava/javafx/FxThread.java new file mode 100644 index 000000000..eca470690 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxThread.java @@ -0,0 +1,171 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import javafx.application.Platform; + +/** + * Getting on and off the JavaFX application thread. + *

+ * NB: this is the one place where JavaFX is meaningfully harder than Swing. + * There is no {@code invokeAndWait}, the toolkit must be started exactly once + * before anything touches it, and it shuts itself down when the last window + * closes unless told otherwise - all of which a caller running a command on a + * background thread should not have to know. + *

+ * + * @author Curtis Rueden + */ +public final class FxThread { + + /** How long to wait for the toolkit itself to come up. */ + private static final long STARTUP_TIMEOUT_SECONDS = 30; + + private static boolean started; + private static boolean failed; + + private FxThread() { + // prevent instantiation of utility class + } + + /** + * Starts the JavaFX toolkit, if it is not already running. + *

+ * NB: it is deliberately not {@code Application.launch}. That method takes + * over the process - it must be called from the main thread, and returns + * only when the last window closes - which is exactly wrong for a toolkit + * binding that an application, a script or a test may use. Starting the + * platform directly leaves the caller in charge. + *

+ */ + public static synchronized void start() { + if (started) return; + // NB: a toolkit that failed once fails the same way every time, and + // saying so immediately is the difference between a skipped test and a + // build that hangs. JavaFX throws IllegalStateException both for + // "already running" and for "startup called twice", so a second attempt + // after a failure would otherwise look like success - and every + // runLater after it would wait for a thread that does not exist. + if (failed) { + throw new IllegalStateException("JavaFX is not available here"); + } + try { + final CountDownLatch ready = new CountDownLatch(1); + try { + Platform.startup(ready::countDown); + } + catch (final IllegalStateException exc) { + // NB: already running, which is fine - somebody else started it. + ready.countDown(); + } + if (!await(ready, STARTUP_TIMEOUT_SECONDS)) { + throw new IllegalStateException("JavaFX did not start within " + + STARTUP_TIMEOUT_SECONDS + " seconds"); + } + // NB: otherwise the platform shuts down for good when the last window + // closes, and the next dialog would never appear. + Platform.setImplicitExit(false); + started = true; + } + catch (final RuntimeException | Error exc) { + failed = true; + throw exc; + } + } + + /** Runs the given work on the JavaFX thread, and waits for it to finish. */ + public static void runAndWait(final Runnable work) { + get(() -> { + work.run(); + return null; + }); + } + + /** + * Runs the given work on the JavaFX thread and returns what it produced. + * + * @param what the work produces + * @param work what to do on the JavaFX thread + * @return its result + */ + public static T get(final Supplier work) { + start(); + if (Platform.isFxApplicationThread()) return work.get(); + final AtomicReference result = new AtomicReference<>(); + final AtomicReference failure = new AtomicReference<>(); + final CountDownLatch done = new CountDownLatch(1); + Platform.runLater(() -> { + try { + result.set(work.get()); + } + catch (final RuntimeException exc) { + failure.set(exc); + } + finally { + done.countDown(); + } + }); + // NB: no timeout here, deliberately. A modal dialog is shown from inside + // this wait and stays up for as long as the user takes. Reaching here at + // all means start() succeeded, which is what makes the wait safe. + await(done); + if (failure.get() != null) throw failure.get(); + return result.get(); + } + + // -- Helper methods -- + + private static void await(final CountDownLatch latch) { + try { + latch.await(); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting for JavaFX", exc); + } + } + + /** Waits, up to a point. Gets whether the latch came down in time. */ + private static boolean await(final CountDownLatch latch, + final long seconds) + { + try { + return latch.await(seconds, java.util.concurrent.TimeUnit.SECONDS); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting for JavaFX", exc); + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxToggle.java b/javafx/src/main/java/org/scijava/javafx/FxToggle.java new file mode 100644 index 000000000..93689747c --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxToggle.java @@ -0,0 +1,85 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import javafx.scene.Node; +import javafx.scene.control.CheckBox; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * A widget for a true/false value. + * + * @author Curtis Rueden + */ +public class FxToggle extends FxWidget { + + private final CheckBox checkBox = new CheckBox(); + + public FxToggle(final ParameterNode node, final ParameterModel model) { + super(node, model); + refresh(); + checkBox.selectedProperty().addListener((obs, old, value) -> update(value)); + } + + @Override + public Node control() { + return checkBox; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final boolean selected = value instanceof Boolean && (Boolean) value; + if (checkBox.isSelected() != selected) checkBox.setSelected(selected); + } + + /** Makes {@link FxToggle}s. */ + @Plugin(type = WidgetFactory.class) + public static class Factory implements FxWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return Widgets.isBoolean(node); + } + + @Override + public FxWidget create(final ParameterNode node, final ParameterModel model, + final WidgetPanelFactory panels) + { + return new FxToggle(node, model); + } + } +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxWidget.java b/javafx/src/main/java/org/scijava/javafx/FxWidget.java new file mode 100644 index 000000000..3ac60cca2 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxWidget.java @@ -0,0 +1,56 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import javafx.scene.Node; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.AbstractWidget; + +/** + * A {@link org.scijava.ui3.Widget} made of JavaFX controls. + *

+ * NB: what is left here, once {@link AbstractWidget} holds the bookkeeping, is + * exactly the toolkit-specific part: which type of control this binding deals + * in. That is the whole of it. + *

+ * + * @author Curtis Rueden + */ +public abstract class FxWidget extends AbstractWidget { + + protected FxWidget(final ParameterNode node, final ParameterModel model) { + super(node, model); + } + + /** Gets the control to place in the dialog. */ + public abstract Node control(); +} diff --git a/javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java b/javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java new file mode 100644 index 000000000..ba4fb3da0 --- /dev/null +++ b/javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java @@ -0,0 +1,45 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import org.scijava.ui3.WidgetFactory; + +/** + * A {@link WidgetFactory} that makes JavaFX widgets. + * + * @author Curtis Rueden + */ +public interface FxWidgetFactory extends WidgetFactory { + + @Override + default Class widgetType() { + return FxWidget.class; + } +} diff --git a/javafx/src/test/java/org/scijava/javafx/FxMenusTest.java b/javafx/src/test/java/org/scijava/javafx/FxMenusTest.java new file mode 100644 index 000000000..6587b4db0 --- /dev/null +++ b/javafx/src/test/java/org/scijava/javafx/FxMenusTest.java @@ -0,0 +1,85 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.List; +import java.util.stream.Collectors; + +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; + +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.command3.MenuTree; +import org.scijava.ui3.test.MenuConformance; + +/** + * Runs the shared menu conformance suite against the JavaFX menus. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class FxMenusTest extends MenuConformance { + + static boolean isHeadless() { + return FxWidgetsTest.isHeadless(); + } + + @Override + protected MenuBar menuBar(final MenuTree root) { + return FxThread.get(() -> FxMenus.create(root, command -> {})); + } + + @Override + protected List topMenus(final MenuBar bar) { + return bar.getMenus().stream().map(Menu::getText) // + .collect(Collectors.toList()); + } + + @Override + protected List items(final MenuBar bar, final String... path) { + Menu menu = bar.getMenus().stream() // + .filter(m -> m.getText().equals(path[0])).findFirst() // + .orElseThrow(() -> new AssertionError("No such menu: " + path[0])); + for (int i = 1; i < path.length; i++) { + menu = (Menu) item(menu, path[i]); + } + return menu.getItems().stream().map(MenuItem::getText) // + .collect(Collectors.toList()); + } + + // -- Helper methods -- + + private static MenuItem item(final Menu menu, final String label) { + return menu.getItems().stream() // + .filter(i -> i.getText().equals(label)).findFirst() // + .orElseThrow(() -> new AssertionError("No such item: " + label)); + } +} diff --git a/javafx/src/test/java/org/scijava/javafx/FxShell.java b/javafx/src/test/java/org/scijava/javafx/FxShell.java new file mode 100644 index 000000000..d8cbd5f7d --- /dev/null +++ b/javafx/src/test/java/org/scijava/javafx/FxShell.java @@ -0,0 +1,210 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.TextField; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import org.scijava.command3.Commands; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; +import org.scijava.context.Context; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Runner; + +/** + * The same small application as {@code org.scijava.swing.Shell}, in JavaFX. + *

+ * Run it with: + *

+ * + *
+ * mvn -pl scijava-javafx test-compile exec:java -Denforcer.skip \
+ *   -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.javafx.FxShell
+ * 
+ *

+ * It is worth comparing the two side by side. The commands are the same + * declarations, the menu tree is the same model walked by the same + * {@code Menus}, the dialogs are built by the same {@code WidgetPanels}: what + * differs is the controls, and this file's plumbing for JavaFX's own + * application thread. + *

+ * + * @author Curtis Rueden + */ +public class FxShell { + + private final Context context = Context.create(); + private final List commands = entries(); + private final Runner runner; + + private final Stage stage = new Stage(); + private final Label status = new Label("Ready"); + private final ProgressBar progress = new ProgressBar(); + private final TextField search = new TextField(); + private final ListView matches = new ListView<>(); + + public FxShell() { + runner = Runner.of(List.of(FxInputHarvester.of(context)), List.of()); + + final BorderPane root = new BorderPane(); + root.setTop(new VBox(FxMenus.create(MenuTree.of(commands), this::run), + searchPane())); + root.setCenter(matches); + root.setBottom(statusPane()); + + matches.setCellFactory(list -> new ListCell<>() { + + @Override + protected void updateItem(final CommandInfo item, final boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : describe(item)); + } + }); + matches.setOnMouseClicked(e -> { + if (e.getClickCount() == 2) runSelected(); + }); + search.textProperty().addListener((obs, old, text) -> filter()); + search.setOnAction(e -> runSelected()); + filter(); + + stage.setTitle("SciJava"); + stage.setScene(new Scene(root, 520, 300)); + stage.setOnCloseRequest(e -> Platform.exit()); + } + + public static void main(final String... args) { + // NB: not Application.launch - see FxThread.start. + FxThread.runAndWait(() -> new FxShell().stage.show()); + } + + // -- Helper methods -- + + /** Gathers what this application can run: commands, and scripts. */ + private List entries() { + final List entries = new java.util.ArrayList<>(Commands.discover( + context)); + final java.net.URL url = FxShell.class.getResource("/scripts"); + if (url != null) { + try { + new org.scijava.script3.ScriptFinder().find(java.nio.file.Paths.get(url + .toURI())).forEach(found -> entries.add(CommandInfo.of(found.script(), + found.metadata()))); + } + catch (final java.net.URISyntaxException exc) { + // NB: no scripts to add, which is not a reason to fail to start. + } + } + return entries; + } + + private HBox searchPane() { + final HBox pane = new HBox(6, new Label("Search:"), search); + pane.setPadding(new Insets(8)); + HBox.setHgrow(search, Priority.ALWAYS); + return pane; + } + + private VBox statusPane() { + progress.setMaxWidth(Double.MAX_VALUE); + progress.setPrefHeight(4); + progress.setVisible(false); + final VBox pane = new VBox(progress, status); + VBox.setMargin(status, new Insets(4, 8, 4, 8)); + return pane; + } + + /** Narrows the list to the commands whose name contains what was typed. */ + private void filter() { + final String text = search.getText().trim().toLowerCase(Locale.ROOT); + final List found = commands.stream() // + .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // + .collect(Collectors.toList()); + matches.setItems(FXCollections.observableArrayList(found)); + if (!found.isEmpty()) matches.getSelectionModel().select(0); + status.setText(found.size() + " of " + commands.size() + " commands"); + } + + private void runSelected() { + final CommandInfo command = matches.getSelectionModel().getSelectedItem(); + if (command != null) run(command); + } + + /** Runs a command, and says what came back. */ + private void run(final CommandInfo command) { + status.setText("Running " + command.label() + "..."); + progress.setVisible(true); + final Future future = runner.run(command, Map.of()); + new Thread(() -> { + final String message = describe(future); + Platform.runLater(() -> { + progress.setVisible(false); + status.setText(message); + }); + }, "shell-" + command.name()).start(); + } + + private static String describe(final CommandInfo command) { + return command.menuPath().map(path -> path.replace(">", " ▸ ")) // + .orElseGet(() -> command.label() + " (not in any menu)"); + } + + private static String describe(final Future future) { + final ExecutionResult result; + try { + result = future.get(); + } + catch (final Exception exc) { + return "Failed: " + exc.getMessage(); + } + if (result.isDeclined()) return result.reason().orElse("Declined"); + if (result.outputs().isEmpty()) return "Done"; + return result.outputs().values().stream().map(String::valueOf) // + .collect(Collectors.joining("; ")); + } +} diff --git a/javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java b/javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java new file mode 100644 index 000000000..f74a7adec --- /dev/null +++ b/javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java @@ -0,0 +1,160 @@ +/* + * #%L + * JavaFX widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.javafx; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Spinner; +import javafx.scene.input.KeyCharacterCombination; +import javafx.scene.input.KeyCombination; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanel; +import org.scijava.ui3.test.SampleCommands; +import org.scijava.ui3.test.WidgetConformance; + +/** + * Runs the shared conformance suite against the JavaFX widgets. + *

+ * It is the same suite the Swing binding runs, which is the point: the + * assertions are about the contracts, so two bindings passing them behave + * alike wherever a caller can tell. + *

+ *

+ * NB: JavaFX controls cannot be constructed at all before the toolkit is + * running, so every widget here is built on the JavaFX thread. + *

+ * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class FxWidgetsTest extends WidgetConformance { + + /** + * NB: not {@code GraphicsEnvironment.isHeadless()} - that is AWT's answer, + * and this module does not read {@code java.desktop} at all. Whether JavaFX + * can start is a question only JavaFX can answer. + */ + static boolean isHeadless() { + try { + FxThread.start(); + return false; + } + catch (final Throwable t) { + return true; + } + } + + private static final List> FACTORIES = List.of( // + new FxMessage.Factory(), // + new FxChoice.Factory(), // + new FxNumber.Factory(), // + new FxToggle.Factory(), // + new FxFile.Factory(), // + new FxText.Factory()); + + @Override + protected WidgetPanel panel(final ParameterModel model) { + return FxThread.get(() -> build(model, FACTORIES, new FxPanel.Factory( + model))); + } + + @Override + protected Class widgetType(final Kind kind) { + switch (kind) { + case MESSAGE: + return FxMessage.class; + case TEXT: + return FxText.class; + case NUMBER: + return FxNumber.class; + case TOGGLE: + return FxToggle.class; + case CHOICE: + return FxChoice.class; + case FILE: + return FxFile.class; + case PANEL: + return FxPanel.class; + default: + throw new IllegalArgumentException(String.valueOf(kind)); + } + } + + /** + * Driving the actual control runs the callback, which is the path a user + * takes and the one a mocked-out test would miss. + */ + @Test + public void testTypingInTheControlRunsCallback() { + final ParameterModel model = model(new SampleCommands.LinkedValues()); + final FxPanel panel = (FxPanel) panel(model); + final FxNumber celsius = (FxNumber) panel.widgets().get(0); + + FxThread.runAndWait(() -> spinner(celsius.control()).getValueFactory() + .setValue(100.0)); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** Finds the spinner inside a widget's controls. */ + @SuppressWarnings("unchecked") + private static Spinner spinner(final Node node) { + if (node instanceof Spinner) return (Spinner) node; + if (node instanceof Parent) { + for (final Node child : ((Parent) node).getChildrenUnmodifiable()) { + final Spinner found = spinner(child); + if (found != null) return found; + } + } + return null; + } + + /** Accelerators read the same notation the Swing binding accepts. */ + @Test + public void testAccelerators() { + final KeyCombination shortcutO = FxMenus.accelerator("^O"); + assertNotNull(shortcutO); + assertEquals(new KeyCharacterCombination("O", KeyCombination.SHORTCUT_DOWN), + shortcutO); + assertEquals(new KeyCharacterCombination("C", KeyCombination.SHORTCUT_DOWN, + KeyCombination.SHIFT_DOWN), FxMenus.accelerator("^+C")); + assertEquals(KeyCombination.keyCombination("Ctrl+Shift+N"), FxMenus + .accelerator("Ctrl+Shift+N")); + } +} diff --git a/javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy @@ -0,0 +1,3 @@ +#@ double(label = "Radius", min = 0, max = 20, style = "slider,ticks:4") sigma +#@output String result +result = "Blurred with sigma " + sigma diff --git a/javafx/src/test/resources/scripts/Say_Hello.groovy b/javafx/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/javafx/src/test/resources/scripts/Say_Hello.groovy @@ -0,0 +1,4 @@ +#@script(menu = "Help>Say Hello From A Script", accelerator = "^H", weight = 50) +#@ String(value = "world") name +#@output String greeting +greeting = "hello " + name diff --git a/scijava-meta/.gitignore b/meta/.gitignore similarity index 100% rename from scijava-meta/.gitignore rename to meta/.gitignore diff --git a/scijava-meta/LICENSE.txt b/meta/LICENSE.txt similarity index 100% rename from scijava-meta/LICENSE.txt rename to meta/LICENSE.txt diff --git a/scijava-meta/README.md b/meta/README.md similarity index 100% rename from scijava-meta/README.md rename to meta/README.md diff --git a/scijava-meta/pom.xml b/meta/pom.xml similarity index 94% rename from scijava-meta/pom.xml rename to meta/pom.xml index 0502e68f9..5278fbe2c 100644 --- a/scijava-meta/pom.xml +++ b/meta/pom.xml @@ -95,6 +95,7 @@ 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT @@ -104,6 +105,11 @@ scijava-common3 ${scijava-common3.version} + + org.scijava + scijava-spi + ${scijava-spi.version} + diff --git a/scijava-meta/src/main/java/module-info.java b/meta/src/main/java/module-info.java similarity index 97% rename from scijava-meta/src/main/java/module-info.java rename to meta/src/main/java/module-info.java index 108acb6e5..a12983cc8 100644 --- a/scijava-meta/src/main/java/module-info.java +++ b/meta/src/main/java/module-info.java @@ -31,6 +31,7 @@ requires java.xml; requires org.scijava.common3; + requires transitive org.scijava.spi; exports org.scijava.meta; } diff --git a/scijava-meta/src/main/java/org/scijava/meta/Manifest.java b/meta/src/main/java/org/scijava/meta/Manifest.java similarity index 99% rename from scijava-meta/src/main/java/org/scijava/meta/Manifest.java rename to meta/src/main/java/org/scijava/meta/Manifest.java index 09d47ebb4..8b52dc53f 100644 --- a/scijava-meta/src/main/java/org/scijava/meta/Manifest.java +++ b/meta/src/main/java/org/scijava/meta/Manifest.java @@ -39,7 +39,7 @@ import java.util.jar.Attributes; import org.scijava.common3.Classes; -import org.scijava.common3.Versioned; +import org.scijava.spi.Versioned; /** * Helper class for working with JAR manifests. diff --git a/scijava-meta/src/main/java/org/scijava/meta/POM.java b/meta/src/main/java/org/scijava/meta/POM.java similarity index 99% rename from scijava-meta/src/main/java/org/scijava/meta/POM.java rename to meta/src/main/java/org/scijava/meta/POM.java index 1d3538351..18c538108 100644 --- a/scijava-meta/src/main/java/org/scijava/meta/POM.java +++ b/meta/src/main/java/org/scijava/meta/POM.java @@ -38,7 +38,7 @@ import org.scijava.common3.Apps; import org.scijava.common3.Classes; import org.scijava.common3.URLs; -import org.scijava.common3.Versioned; +import org.scijava.spi.Versioned; /** * Helper class for working with Maven POMs. diff --git a/scijava-meta/src/main/java/org/scijava/meta/Versions.java b/meta/src/main/java/org/scijava/meta/Versions.java similarity index 100% rename from scijava-meta/src/main/java/org/scijava/meta/Versions.java rename to meta/src/main/java/org/scijava/meta/Versions.java diff --git a/scijava-meta/src/main/java/org/scijava/meta/XML.java b/meta/src/main/java/org/scijava/meta/XML.java similarity index 100% rename from scijava-meta/src/main/java/org/scijava/meta/XML.java rename to meta/src/main/java/org/scijava/meta/XML.java diff --git a/scijava-meta/src/test/java/org/scijava/meta/POMTest.java b/meta/src/test/java/org/scijava/meta/POMTest.java similarity index 100% rename from scijava-meta/src/test/java/org/scijava/meta/POMTest.java rename to meta/src/test/java/org/scijava/meta/POMTest.java diff --git a/scijava-meta/src/test/java/org/scijava/meta/VersionsTest.java b/meta/src/test/java/org/scijava/meta/VersionsTest.java similarity index 100% rename from scijava-meta/src/test/java/org/scijava/meta/VersionsTest.java rename to meta/src/test/java/org/scijava/meta/VersionsTest.java diff --git a/scijava-ops-api/.gitignore b/ops-api/.gitignore similarity index 100% rename from scijava-ops-api/.gitignore rename to ops-api/.gitignore diff --git a/scijava-ops-api/LICENSE.txt b/ops-api/LICENSE.txt similarity index 100% rename from scijava-ops-api/LICENSE.txt rename to ops-api/LICENSE.txt diff --git a/scijava-ops-api/README.md b/ops-api/README.md similarity index 100% rename from scijava-ops-api/README.md rename to ops-api/README.md diff --git a/scijava-ops-api/bin/generate.groovy b/ops-api/bin/generate.groovy similarity index 100% rename from scijava-ops-api/bin/generate.groovy rename to ops-api/bin/generate.groovy diff --git a/scijava-ops-api/pom.xml b/ops-api/pom.xml similarity index 95% rename from scijava-ops-api/pom.xml rename to ops-api/pom.xml index e110a8466..7762c6812 100644 --- a/scijava-ops-api/pom.xml +++ b/ops-api/pom.xml @@ -99,6 +99,7 @@ 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT @@ -126,6 +127,11 @@ scijava-priority ${scijava-priority.version} + + org.scijava + scijava-spi + ${scijava-spi.version} + org.scijava scijava-struct diff --git a/scijava-ops-api/src/main/java/module-info.java b/ops-api/src/main/java/module-info.java similarity index 97% rename from scijava-ops-api/src/main/java/module-info.java rename to ops-api/src/main/java/module-info.java index 996975ecc..9733260ec 100644 --- a/scijava-ops-api/src/main/java/module-info.java +++ b/ops-api/src/main/java/module-info.java @@ -31,6 +31,7 @@ exports org.scijava.ops.api; requires org.scijava.common3; + requires transitive org.scijava.spi; requires org.scijava.function; requires transitive org.scijava.priority; requires transitive org.scijava.struct; diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/Hints.java b/ops-api/src/main/java/org/scijava/ops/api/Hints.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/Hints.java rename to ops-api/src/main/java/org/scijava/ops/api/Hints.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/InfoTree.java b/ops-api/src/main/java/org/scijava/ops/api/InfoTree.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/InfoTree.java rename to ops-api/src/main/java/org/scijava/ops/api/InfoTree.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpBuilder.java b/ops-api/src/main/java/org/scijava/ops/api/OpBuilder.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpBuilder.java rename to ops-api/src/main/java/org/scijava/ops/api/OpBuilder.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java b/ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java similarity index 99% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java rename to ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java index e565b0779..ac0d87223 100644 --- a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java +++ b/ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java @@ -73,7 +73,7 @@ public interface OpEnvironment extends Prioritized { */ static OpEnvironment buildEmpty() { var opsOptional = Discoverer // - .using(ServiceLoader::load) // + .usingProviders((Class c) -> ServiceLoader.load(c).stream()) // .discoverMax(OpEnvironment.class); return opsOptional.orElseThrow( // () -> new RuntimeException("No OpEnvironment Provided!") // @@ -333,7 +333,7 @@ default OpBuilder op(final String opName, final Hints hints) { * contract of the stated {@code opType}. * @return An enriched version of the lambda object that embeds knowledge of * its generic type by implementing the - * {@link org.scijava.common3.GenericTyped} interface. + * {@link org.scijava.spi.GenericTyped} interface. */ T typeLambda(Nil opType, T lambda); diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpHistory.java b/ops-api/src/main/java/org/scijava/ops/api/OpHistory.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpHistory.java rename to ops-api/src/main/java/org/scijava/ops/api/OpHistory.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpInfo.java b/ops-api/src/main/java/org/scijava/ops/api/OpInfo.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpInfo.java rename to ops-api/src/main/java/org/scijava/ops/api/OpInfo.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpInstance.java b/ops-api/src/main/java/org/scijava/ops/api/OpInstance.java similarity index 98% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpInstance.java rename to ops-api/src/main/java/org/scijava/ops/api/OpInstance.java index eba19ab64..57bac2b41 100644 --- a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpInstance.java +++ b/ops-api/src/main/java/org/scijava/ops/api/OpInstance.java @@ -32,7 +32,7 @@ import java.lang.reflect.Type; import java.util.Objects; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; /** * An instance of an {@link OpInfo}. They can be constructed directly, but are diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpMatchingException.java b/ops-api/src/main/java/org/scijava/ops/api/OpMatchingException.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpMatchingException.java rename to ops-api/src/main/java/org/scijava/ops/api/OpMatchingException.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpRequest.java b/ops-api/src/main/java/org/scijava/ops/api/OpRequest.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/OpRequest.java rename to ops-api/src/main/java/org/scijava/ops/api/OpRequest.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/Ops.java b/ops-api/src/main/java/org/scijava/ops/api/Ops.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/Ops.java rename to ops-api/src/main/java/org/scijava/ops/api/Ops.java diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/RichOp.java b/ops-api/src/main/java/org/scijava/ops/api/RichOp.java similarity index 98% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/RichOp.java rename to ops-api/src/main/java/org/scijava/ops/api/RichOp.java index 7d75eaa87..ba43634a7 100644 --- a/scijava-ops-api/src/main/java/org/scijava/ops/api/RichOp.java +++ b/ops-api/src/main/java/org/scijava/ops/api/RichOp.java @@ -31,7 +31,7 @@ import java.lang.reflect.Type; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; /** * An {@link OpInstance} with state diff --git a/scijava-ops-api/src/main/java/org/scijava/ops/api/package-info.java b/ops-api/src/main/java/org/scijava/ops/api/package-info.java similarity index 100% rename from scijava-ops-api/src/main/java/org/scijava/ops/api/package-info.java rename to ops-api/src/main/java/org/scijava/ops/api/package-info.java diff --git a/scijava-ops-api/templates/main/java/org/scijava/ops/api/Globals.list b/ops-api/templates/main/java/org/scijava/ops/api/Globals.list similarity index 100% rename from scijava-ops-api/templates/main/java/org/scijava/ops/api/Globals.list rename to ops-api/templates/main/java/org/scijava/ops/api/Globals.list diff --git a/scijava-ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.list b/ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.list similarity index 100% rename from scijava-ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.list rename to ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.list diff --git a/scijava-ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.vm b/ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.vm similarity index 100% rename from scijava-ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.vm rename to ops-api/templates/main/java/org/scijava/ops/api/OpBuilder.vm diff --git a/scijava-ops-benchmarks/.gitignore b/ops-benchmarks/.gitignore similarity index 100% rename from scijava-ops-benchmarks/.gitignore rename to ops-benchmarks/.gitignore diff --git a/scijava-legacy/LICENSE.txt b/ops-benchmarks/LICENSE.txt similarity index 100% rename from scijava-legacy/LICENSE.txt rename to ops-benchmarks/LICENSE.txt diff --git a/scijava-ops-benchmarks/README.md b/ops-benchmarks/README.md similarity index 51% rename from scijava-ops-benchmarks/README.md rename to ops-benchmarks/README.md index ed225403c..b1b657ff5 100644 --- a/scijava-ops-benchmarks/README.md +++ b/ops-benchmarks/README.md @@ -4,21 +4,21 @@ This module contains benchmark code used to assess the performance of the SciJav # Executing the Benchmarks -The following lines can be used to build and execute the benchmarks from the base `scijava-ops-benchmarks` directory on the command line: +The following lines can be used to build and execute the benchmarks from the base `ops-benchmarks` directory on the command line: ```bash # Build the benchmarks module cd .. -mvn -P benchmarks clean install -pl scijava-ops-benchmarks -am +mvn -P benchmarks clean install -pl ops-benchmarks -am # Copy dependencies into target folder -cd scijava-ops-benchmarks +cd ops-benchmarks mvn dependency:copy-dependencies # Execute the benchmarks -java -cp "target/scijava-ops-benchmarks-1.0.0.jar:target/dependency/*" org.openjdk.jmh.Main +java -cp "target/classes:target/dependency/*" org.openjdk.jmh.Main ``` # Adding a new benchmark -The best way to create a new benchmark is to create a new Java class within `src/main/java/org/scijava/benchmarks`. Within this new class, you can add new methods (annotated with `@Benchmark`) which will be automatically invoked when benchmarks are executed using the commands above. +The best way to create a new benchmark is to create a new Java class within `src/main/java/org/scijava/ops/benchmarks`. Within this new class, you can add new methods (annotated with `@Benchmark`) which will be automatically invoked when benchmarks are executed using the commands above. diff --git a/scijava-ops-benchmarks/pom.xml b/ops-benchmarks/pom.xml similarity index 98% rename from scijava-ops-benchmarks/pom.xml rename to ops-benchmarks/pom.xml index c81ce123e..dc1c3a3ba 100644 --- a/scijava-ops-benchmarks/pom.xml +++ b/ops-benchmarks/pom.xml @@ -100,7 +100,9 @@ true + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT diff --git a/scijava-ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/BenchmarkState.java b/ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/BenchmarkState.java similarity index 100% rename from scijava-ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/BenchmarkState.java rename to ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/BenchmarkState.java diff --git a/scijava-ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/Benchmarks.java b/ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/Benchmarks.java similarity index 100% rename from scijava-ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/Benchmarks.java rename to ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/Benchmarks.java diff --git a/scijava-ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/OpsToBenchmark.java b/ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/OpsToBenchmark.java similarity index 100% rename from scijava-ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/OpsToBenchmark.java rename to ops-benchmarks/src/main/java/org/scijava/ops/benchmarks/OpsToBenchmark.java diff --git a/scijava-ops-benchmarks/src/test/java/org/scijava/ops/benchmarks/matching/OpsToBenchmarkTest.java b/ops-benchmarks/src/test/java/org/scijava/ops/benchmarks/matching/OpsToBenchmarkTest.java similarity index 100% rename from scijava-ops-benchmarks/src/test/java/org/scijava/ops/benchmarks/matching/OpsToBenchmarkTest.java rename to ops-benchmarks/src/test/java/org/scijava/ops/benchmarks/matching/OpsToBenchmarkTest.java diff --git a/scijava-ops-benchmarks/LICENSE.txt b/ops-compat/LICENSE.txt similarity index 100% rename from scijava-ops-benchmarks/LICENSE.txt rename to ops-compat/LICENSE.txt diff --git a/ops-compat/README.md b/ops-compat/README.md new file mode 100644 index 000000000..4e45a3919 --- /dev/null +++ b/ops-compat/README.md @@ -0,0 +1,17 @@ +# SciJava Ops Compat: A backwards compatibility bridge with older SciJava code + +This library provides a bridge between SciJava Ops +and previous generations (Java 8 / pre-JPMS) of SciJava code. + +It integrates SciJava Ops into SciJava Common's application context +and module system, via an OpEnvironmentService and preprocessor plugin. + +For Maven projects, this library can be used by adding to your `pom.xml`: + +```xml + + org.scijava + scijava-ops-compat + 1.0.0 + +``` diff --git a/scijava-legacy/pom.xml b/ops-compat/pom.xml similarity index 88% rename from scijava-legacy/pom.xml rename to ops-compat/pom.xml index 120d28c43..c322f97ad 100644 --- a/scijava-legacy/pom.xml +++ b/ops-compat/pom.xml @@ -9,11 +9,11 @@ - scijava-legacy + scijava-ops-compat 1.0.1-SNAPSHOT - SciJava Legacy - Interoperability with legacy SciJava libraries. + SciJava Ops Compat + SciJava Ops, usable from a SciJava Common application. https://github.com/scijava/scijava 2023 @@ -79,24 +79,35 @@ - org.scijava.legacy + org.scijava.ops.compat bsd_2 SciJava developers. + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + org.scijava scijava-discovery @@ -107,39 +118,32 @@ scijava-ops-api ${scijava-ops-api.version} - - org.scijava - scijava-priority - ${scijava-priority.version} - org.scijava scijava-progress ${scijava-progress.version} - - org.scijava - scijava-types - ${scijava-types.version} - - - net.imglib2 - imglib2 - org.scijava scijava-common - - net.imagej - imagej-common - + + + org.scijava + scijava-ops-engine + ${scijava-ops-engine.version} + test + org.junit.jupiter junit-jupiter-api @@ -155,12 +159,6 @@ due to the dependency no longer being on the compile-time module-path. --> - - org.scijava - scijava-ops-engine - ${scijava-ops-engine.version} - test - diff --git a/scijava-legacy/src/main/java/module-info.java b/ops-compat/src/main/java/module-info.java similarity index 87% rename from scijava-legacy/src/main/java/module-info.java rename to ops-compat/src/main/java/module-info.java index 29b742ed4..b6d5d1f77 100644 --- a/scijava-legacy/src/main/java/module-info.java +++ b/ops-compat/src/main/java/module-info.java @@ -27,14 +27,13 @@ * #L% */ -open module org.scijava.legacy { +open module org.scijava.ops.compat { - requires net.imagej; + exports org.scijava.ops.compat; + + // NB: SciJava Common, whose services and preprocessors these are. requires org.scijava; - requires org.scijava.types; - requires org.scijava.priority; requires org.scijava.ops.api; requires org.scijava.progress; - provides org.scijava.types.extract.TypeExtractor with org.scijava.legacy.types.DatasetTypeExtractor; } diff --git a/scijava-legacy/src/main/java/org/scijava/legacy/service/DefaultOpEnvironmentService.java b/ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java similarity index 99% rename from scijava-legacy/src/main/java/org/scijava/legacy/service/DefaultOpEnvironmentService.java rename to ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java index 762ffeac1..1a578ee5b 100644 --- a/scijava-legacy/src/main/java/org/scijava/legacy/service/DefaultOpEnvironmentService.java +++ b/ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.legacy.service; +package org.scijava.ops.compat; import org.scijava.ops.api.Hints; import org.scijava.ops.api.OpEnvironment; diff --git a/scijava-legacy/src/main/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorPlugin.java b/ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java similarity index 96% rename from scijava-legacy/src/main/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorPlugin.java rename to ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java index 872eccb50..44b0eac1e 100644 --- a/scijava-legacy/src/main/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorPlugin.java +++ b/ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java @@ -27,10 +27,9 @@ * #L% */ -package org.scijava.legacy.module.process; +package org.scijava.ops.compat; import org.scijava.Priority; -import org.scijava.legacy.service.OpEnvironmentService; import org.scijava.module.Module; import org.scijava.module.ModuleItem; import org.scijava.module.process.AbstractPreprocessorPlugin; diff --git a/scijava-legacy/src/main/java/org/scijava/legacy/service/OpEnvironmentService.java b/ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java similarity index 98% rename from scijava-legacy/src/main/java/org/scijava/legacy/service/OpEnvironmentService.java rename to ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java index 3bb341626..92497ff4d 100644 --- a/scijava-legacy/src/main/java/org/scijava/legacy/service/OpEnvironmentService.java +++ b/ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.legacy.service; +package org.scijava.ops.compat; import org.scijava.ops.api.OpEnvironment; import org.scijava.service.SciJavaService; diff --git a/scijava-legacy/src/test/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorTest.java b/ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java similarity index 96% rename from scijava-legacy/src/test/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorTest.java rename to ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java index 703c8327e..d3fdc4283 100644 --- a/scijava-legacy/src/test/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorTest.java +++ b/ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java @@ -27,13 +27,12 @@ * #L% */ -package org.scijava.legacy.module.process; +package org.scijava.ops.compat; import org.junit.jupiter.api.Test; import org.scijava.Context; import org.scijava.command.Command; import org.scijava.command.CommandService; -import org.scijava.legacy.service.OpEnvironmentService; import org.scijava.ops.api.OpEnvironment; import org.scijava.plugin.Parameter; diff --git a/scijava-legacy/src/test/java/org/scijava/legacy/service/OpEnvironmentServiceTest.java b/ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java similarity index 99% rename from scijava-legacy/src/test/java/org/scijava/legacy/service/OpEnvironmentServiceTest.java rename to ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java index 7cad3a1b1..e0d0ae0b6 100644 --- a/scijava-legacy/src/test/java/org/scijava/legacy/service/OpEnvironmentServiceTest.java +++ b/ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.legacy.service; +package org.scijava.ops.compat; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/scijava-ops-engine/.gitignore b/ops-engine/.gitignore similarity index 100% rename from scijava-ops-engine/.gitignore rename to ops-engine/.gitignore diff --git a/scijava-ops-engine/LICENSE.txt b/ops-engine/LICENSE.txt similarity index 100% rename from scijava-ops-engine/LICENSE.txt rename to ops-engine/LICENSE.txt diff --git a/scijava-ops-engine/README.md b/ops-engine/README.md similarity index 100% rename from scijava-ops-engine/README.md rename to ops-engine/README.md diff --git a/scijava-ops-engine/bin/generate.groovy b/ops-engine/bin/generate.groovy similarity index 100% rename from scijava-ops-engine/bin/generate.groovy rename to ops-engine/bin/generate.groovy diff --git a/scijava-ops-engine/pom.xml b/ops-engine/pom.xml similarity index 98% rename from scijava-ops-engine/pom.xml rename to ops-engine/pom.xml index 21c88198c..3079ce879 100644 --- a/scijava-ops-engine/pom.xml +++ b/ops-engine/pom.xml @@ -127,6 +127,7 @@ 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT @@ -184,6 +185,11 @@ scijava-progress ${scijava-progress.version} + + org.scijava + scijava-spi + ${scijava-spi.version} + org.scijava scijava-struct diff --git a/scijava-ops-engine/src/it/settings.xml b/ops-engine/src/it/settings.xml similarity index 100% rename from scijava-ops-engine/src/it/settings.xml rename to ops-engine/src/it/settings.xml diff --git a/scijava-ops-engine/src/it/test-ops-api/pom.xml b/ops-engine/src/it/test-ops-api/pom.xml similarity index 100% rename from scijava-ops-engine/src/it/test-ops-api/pom.xml rename to ops-engine/src/it/test-ops-api/pom.xml diff --git a/scijava-ops-engine/src/it/test-ops-api/src/main/java/OpsAPITest.java b/ops-engine/src/it/test-ops-api/src/main/java/OpsAPITest.java similarity index 100% rename from scijava-ops-engine/src/it/test-ops-api/src/main/java/OpsAPITest.java rename to ops-engine/src/it/test-ops-api/src/main/java/OpsAPITest.java diff --git a/scijava-ops-engine/src/main/java/module-info.java b/ops-engine/src/main/java/module-info.java similarity index 99% rename from scijava-ops-engine/src/main/java/module-info.java rename to ops-engine/src/main/java/module-info.java index 3da250323..c0243778b 100644 --- a/scijava-ops-engine/src/main/java/module-info.java +++ b/ops-engine/src/main/java/module-info.java @@ -35,6 +35,7 @@ requires java.compiler; requires org.scijava.common3; + requires org.scijava.spi; requires org.scijava.collections; requires org.scijava.discovery; requires org.scijava.function; diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/BaseOpHints.java b/ops-engine/src/main/java/org/scijava/ops/engine/BaseOpHints.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/BaseOpHints.java rename to ops-engine/src/main/java/org/scijava/ops/engine/BaseOpHints.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/DependencyMatchingException.java b/ops-engine/src/main/java/org/scijava/ops/engine/DependencyMatchingException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/DependencyMatchingException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/DependencyMatchingException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/InfoTreeGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/InfoTreeGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/InfoTreeGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/InfoTreeGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/MatchingConditions.java b/ops-engine/src/main/java/org/scijava/ops/engine/MatchingConditions.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/MatchingConditions.java rename to ops-engine/src/main/java/org/scijava/ops/engine/MatchingConditions.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpDependencyMember.java b/ops-engine/src/main/java/org/scijava/ops/engine/OpDependencyMember.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpDependencyMember.java rename to ops-engine/src/main/java/org/scijava/ops/engine/OpDependencyMember.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpDescriptionGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/OpDescriptionGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpDescriptionGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/OpDescriptionGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpInfoGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/OpInfoGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpInfoGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/OpInfoGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpWrapper.java b/ops-engine/src/main/java/org/scijava/ops/engine/OpWrapper.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/OpWrapper.java rename to ops-engine/src/main/java/org/scijava/ops/engine/OpWrapper.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.java b/ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.java rename to ops-engine/src/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LossReporter.java b/ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LossReporter.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LossReporter.java rename to ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LossReporter.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LosslessReporter.java b/ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LosslessReporter.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LosslessReporter.java rename to ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/LosslessReporter.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/IdentityLossReporter.java b/ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/IdentityLossReporter.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/IdentityLossReporter.java rename to ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/IdentityLossReporter.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveArrayLossReporters.java b/ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveArrayLossReporters.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveArrayLossReporters.java rename to ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveArrayLossReporters.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveLossReporters.java b/ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveLossReporters.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveLossReporters.java rename to ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveLossReporters.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/package-info.java b/ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/package-info.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/package-info.java rename to ops-engine/src/main/java/org/scijava/ops/engine/conversionLoss/package-info.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/copy/CopyOpCollection.java b/ops-engine/src/main/java/org/scijava/ops/engine/copy/CopyOpCollection.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/copy/CopyOpCollection.java rename to ops-engine/src/main/java/org/scijava/ops/engine/copy/CopyOpCollection.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/create/CreateOpCollection.java b/ops-engine/src/main/java/org/scijava/ops/engine/create/CreateOpCollection.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/create/CreateOpCollection.java rename to ops-engine/src/main/java/org/scijava/ops/engine/create/CreateOpCollection.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/describe/BaseDescriptors.java b/ops-engine/src/main/java/org/scijava/ops/engine/describe/BaseDescriptors.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/describe/BaseDescriptors.java rename to ops-engine/src/main/java/org/scijava/ops/engine/describe/BaseDescriptors.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/describe/TypeDescriptor.java b/ops-engine/src/main/java/org/scijava/ops/engine/describe/TypeDescriptor.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/describe/TypeDescriptor.java rename to ops-engine/src/main/java/org/scijava/ops/engine/describe/TypeDescriptor.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/eval/DefaultEval.java b/ops-engine/src/main/java/org/scijava/ops/engine/eval/DefaultEval.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/eval/DefaultEval.java rename to ops-engine/src/main/java/org/scijava/ops/engine/eval/DefaultEval.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/eval/OpEvaluator.java b/ops-engine/src/main/java/org/scijava/ops/engine/eval/OpEvaluator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/eval/OpEvaluator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/eval/OpEvaluator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/InvalidOpException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/InvalidOpException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/InvalidOpException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/InvalidOpException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FinalOpDependencyFieldException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FinalOpDependencyFieldException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FinalOpDependencyFieldException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FinalOpDependencyFieldException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FunctionalTypeOpException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FunctionalTypeOpException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FunctionalTypeOpException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/FunctionalTypeOpException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InstanceOpMethodException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InstanceOpMethodException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InstanceOpMethodException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InstanceOpMethodException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InvalidOpNameException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InvalidOpNameException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InvalidOpNameException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/InvalidOpNameException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/MultipleOutputsOpException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/MultipleOutputsOpException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/MultipleOutputsOpException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/MultipleOutputsOpException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/NullablesOnMultipleMethodsException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/NullablesOnMultipleMethodsException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/NullablesOnMultipleMethodsException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/NullablesOnMultipleMethodsException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/OpDependencyPositionException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/OpDependencyPositionException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/OpDependencyPositionException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/OpDependencyPositionException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/PrivateOpException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/PrivateOpException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/PrivateOpException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/PrivateOpException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnnamedOpException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnnamedOpException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnnamedOpException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnnamedOpException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnreadableOpException.java b/ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnreadableOpException.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnreadableOpException.java rename to ops-engine/src/main/java/org/scijava/ops/engine/exceptions/impl/UnreadableOpException.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultInfoTreeGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultInfoTreeGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultInfoTreeGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultInfoTreeGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java similarity index 98% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java index 271a8538e..444877927 100644 --- a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java +++ b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java @@ -77,8 +77,8 @@ public class DefaultOpEnvironment implements OpEnvironment { // A Discoverer used to discover backend plugins, like OpInfoGenerators - private final Discoverer metaDiscoverer = Discoverer.union(Discoverer.all( - ServiceLoader::load)); + private final Discoverer metaDiscoverer = Discoverer.union(Discoverer.all( // + Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()))); private final List discoverers = new ArrayList<>(); @@ -136,7 +136,7 @@ public DefaultOpEnvironment(final Discoverer... discoverers) { typeReifier = new DefaultTypeReifier(metaDiscoverer); history = OpHistory.getOpHistory(); matcher = new DefaultOpMatcher( // - metaDiscoverer.discover(MatchingRoutine.class) // + metaDiscoverer.instances(MatchingRoutine.class) // ); discoverUsing(discoverers); discoverUsing(manDiscoverer); @@ -173,9 +173,9 @@ public SortedSet infos(String name, Hints hints) { public void discoverUsing(Discoverer... arr) { for (var d : arr) { discoverers.add(d); - d.discover(OpInfo.class).forEach(this::registerInfosFrom); - d.discover(Op.class).forEach(this::registerInfosFrom); - d.discover(OpCollection.class).forEach(this::registerInfosFrom); + d.instances(OpInfo.class).forEach(this::registerInfosFrom); + d.instances(Op.class).forEach(this::registerInfosFrom); + d.instances(OpCollection.class).forEach(this::registerInfosFrom); } } @@ -236,7 +236,7 @@ public T opFromInfoTree(final InfoTree tree, final Nil specialType, public InfoTree treeFromSignature(String signature) { if (idDirectory == null) initIdDirectory(); var infoTreeGenerators = discoverers.stream() // - .flatMap(d -> d.discover(InfoTreeGenerator.class).stream()) // + .flatMap(d -> d.instances(InfoTreeGenerator.class).stream()) // .collect(Collectors.toList()); var genOpt = InfoTreeGenerator.findSuitableGenerator( @@ -301,7 +301,7 @@ else if (o instanceof Iterable) { */ private List generateAllInfos(Object o) { // Find all OpInfoGenerators - return metaDiscoverer.discover(OpInfoGenerator.class) // + return metaDiscoverer.instances(OpInfoGenerator.class) // .stream() // // Filter to the ones that can operate on o .filter(g -> g.canGenerateFrom(o)) // @@ -562,7 +562,7 @@ private synchronized void initWrappers() { if (wrappers != null) return; var tmp = new HashMap, OpWrapper>(); for (var d : discoverers) - for (OpWrapper wrapper : d.discover(OpWrapper.class)) + for (OpWrapper wrapper : d.instances(OpWrapper.class)) tmp.put(wrapper.type(), wrapper); wrappers = tmp; } diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpHistory.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpHistory.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpHistory.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpHistory.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyOpInstanceInfoTree.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyOpInstanceInfoTree.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyOpInstanceInfoTree.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyOpInstanceInfoTree.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyRichOpInfoTree.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyRichOpInfoTree.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyRichOpInfoTree.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/DependencyRichOpInfoTree.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java similarity index 99% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java index ccd49715b..815055553 100644 --- a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java +++ b/ops-engine/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java @@ -34,7 +34,7 @@ import java.lang.reflect.Modifier; import java.lang.reflect.Type; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; import org.scijava.common3.Types; import javassist.CannotCompileException; diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/OpClassOpInfoGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/OpClassOpInfoGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/OpClassOpInfoGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/OpClassOpInfoGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/OpCollectionInfoGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/impl/OpCollectionInfoGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/OpCollectionInfoGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/impl/OpCollectionInfoGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingResult.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingResult.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingResult.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingResult.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingRoutine.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingRoutine.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingRoutine.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/MatchingRoutine.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpCandidate.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpCandidate.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpCandidate.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpCandidate.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpMatcher.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpMatcher.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpMatcher.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/OpMatcher.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationInfoTreeGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationInfoTreeGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationInfoTreeGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationInfoTreeGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationMatchingRoutine.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationMatchingRoutine.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationMatchingRoutine.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationMatchingRoutine.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConversionMatchingRoutine.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConversionMatchingRoutine.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConversionMatchingRoutine.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConversionMatchingRoutine.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedInfoTreeGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedInfoTreeGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedInfoTreeGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedInfoTreeGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedOpInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedOpInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedOpInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedOpInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/IdentityCollection.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/IdentityCollection.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/IdentityCollection.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/IdentityCollection.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ListConverterCollection.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ListConverterCollection.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ListConverterCollection.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/ListConverterCollection.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverters.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverters.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverters.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverters.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveConverters.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveConverters.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveConverters.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveConverters.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/UtilityConverters.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/UtilityConverters.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/UtilityConverters.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/convert/UtilityConverters.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/AbstractRichOp.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/AbstractRichOp.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/AbstractRichOp.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/AbstractRichOp.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpClassInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpClassInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpClassInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpClassInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMatcher.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMatcher.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMatcher.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMatcher.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMethodInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMethodInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMethodInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMethodInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpRequest.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpRequest.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpRequest.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpRequest.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/InfoMatchingOpRequest.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/InfoMatchingOpRequest.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/InfoMatchingOpRequest.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/InfoMatchingOpRequest.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/LossReporterWrapper.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/LossReporterWrapper.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/LossReporterWrapper.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/LossReporterWrapper.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/MatchingUtils.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/MatchingUtils.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/MatchingUtils.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/MatchingUtils.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/RuntimeSafeMatchingRoutine.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/RuntimeSafeMatchingRoutine.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/RuntimeSafeMatchingRoutine.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/impl/RuntimeSafeMatchingRoutine.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/AbstractInfoReducer.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/AbstractInfoReducer.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/AbstractInfoReducer.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/AbstractInfoReducer.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ComputerReducer.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ComputerReducer.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ComputerReducer.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ComputerReducer.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/FunctionReducer.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/FunctionReducer.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/FunctionReducer.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/FunctionReducer.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/InfoReducer.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/InfoReducer.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/InfoReducer.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/InfoReducer.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedInfoTreeGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedInfoTreeGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedInfoTreeGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedInfoTreeGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfoGenerator.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfoGenerator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfoGenerator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfoGenerator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java b/ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java rename to ops-engine/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Add.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/Add.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Add.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/Add.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/MathOpCollection.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/MathOpCollection.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/MathOpCollection.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/MathOpCollection.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/MathOps.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/MathOps.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/MathOps.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/MathOps.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Normalize.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/Normalize.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Normalize.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/Normalize.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Power.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/Power.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Power.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/Power.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Sqrt.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/Sqrt.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Sqrt.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/Sqrt.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Zero.java b/ops-engine/src/main/java/org/scijava/ops/engine/math/Zero.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/math/Zero.java rename to ops-engine/src/main/java/org/scijava/ops/engine/math/Zero.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/package-info.java b/ops-engine/src/main/java/org/scijava/ops/engine/package-info.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/package-info.java rename to ops-engine/src/main/java/org/scijava/ops/engine/package-info.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/stats/Mean.java b/ops-engine/src/main/java/org/scijava/ops/engine/stats/Mean.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/stats/Mean.java rename to ops-engine/src/main/java/org/scijava/ops/engine/stats/Mean.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/stats/Size.java b/ops-engine/src/main/java/org/scijava/ops/engine/stats/Size.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/stats/Size.java rename to ops-engine/src/main/java/org/scijava/ops/engine/stats/Size.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/AnnotatedOpDependencyMember.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/AnnotatedOpDependencyMember.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/AnnotatedOpDependencyMember.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/AnnotatedOpDependencyMember.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassOpDependencyMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassOpDependencyMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassOpDependencyMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassOpDependencyMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassParameterMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassParameterMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassParameterMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/ClassParameterMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ConvertedParameterMember.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/ConvertedParameterMember.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ConvertedParameterMember.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/ConvertedParameterMember.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldInstance.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldInstance.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldInstance.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldInstance.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldOpDependencyMember.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldOpDependencyMember.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldOpDependencyMember.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldOpDependencyMember.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldParameterMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldParameterMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldParameterMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/FieldParameterMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalMethodType.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalMethodType.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalMethodType.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalMethodType.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalParameters.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalParameters.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalParameters.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/FunctionalParameters.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodOpDependencyMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodOpDependencyMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodOpDependencyMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodOpDependencyMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterOpDependencyMember.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterOpDependencyMember.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterOpDependencyMember.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/MethodParameterOpDependencyMember.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/OpResizingMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/OpResizingMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/OpResizingMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/OpResizingMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/OpRetypingMemberParser.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/OpRetypingMemberParser.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/OpRetypingMemberParser.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/OpRetypingMemberParser.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ParameterData.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/ParameterData.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/ParameterData.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/ParameterData.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/RetypingRequest.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/RetypingRequest.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/RetypingRequest.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/RetypingRequest.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedFieldParameterData.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedFieldParameterData.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedFieldParameterData.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedFieldParameterData.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedMethodParameterData.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedMethodParameterData.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedMethodParameterData.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedMethodParameterData.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedParameterMember.java b/ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedParameterMember.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedParameterMember.java rename to ops-engine/src/main/java/org/scijava/ops/engine/struct/SynthesizedParameterMember.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/FunctionUtils.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/FunctionUtils.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/FunctionUtils.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/FunctionUtils.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/Infos.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/Infos.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/Infos.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/Infos.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/Lambdas.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/Lambdas.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/Lambdas.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/Lambdas.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/Maps.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/Maps.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/Maps.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/Maps.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/AnnotationUtils.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/internal/AnnotationUtils.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/AnnotationUtils.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/internal/AnnotationUtils.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/OpMethodUtils.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/internal/OpMethodUtils.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/OpMethodUtils.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/internal/OpMethodUtils.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/package-info.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/internal/package-info.java similarity index 95% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/package-info.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/internal/package-info.java index 0aa607e3e..65040b8e2 100644 --- a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/internal/package-info.java +++ b/ops-engine/src/main/java/org/scijava/ops/engine/util/internal/package-info.java @@ -31,7 +31,7 @@ * This package is designed to house utility methods intended for use only * within SciJava Ops. *

- * Utilities intended for use outside of this library sould be housed within + * Utilities intended for use outside of this library should be housed within * the {@code org.scijava.ops.engine.util} package. * * @author Gabriel Selzer diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/package-info.java b/ops-engine/src/main/java/org/scijava/ops/engine/util/package-info.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/util/package-info.java rename to ops-engine/src/main/java/org/scijava/ops/engine/util/package-info.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLOpInfoCreator.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLOpInfoCreator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLOpInfoCreator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLOpInfoCreator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLUtils.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLUtils.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLUtils.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/YAMLUtils.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/AbstractYAMLOpInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/AbstractYAMLOpInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/AbstractYAMLOpInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/AbstractYAMLOpInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaClassYAMLOpInfoCreator.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaClassYAMLOpInfoCreator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaClassYAMLOpInfoCreator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaClassYAMLOpInfoCreator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaFieldYAMLOpInfoCreator.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaFieldYAMLOpInfoCreator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaFieldYAMLOpInfoCreator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaFieldYAMLOpInfoCreator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaMethodYAMLInfoCreator.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaMethodYAMLInfoCreator.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaMethodYAMLInfoCreator.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/JavaMethodYAMLInfoCreator.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpClassInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpClassInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpClassInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpClassInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpFieldInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpFieldInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpFieldInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpFieldInfo.java diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java similarity index 88% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java index 2767b4954..feb00bb34 100644 --- a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java +++ b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java @@ -42,9 +42,11 @@ import java.util.Map; import java.util.Optional; import java.util.ServiceLoader; +import java.util.stream.Collectors; import org.scijava.common3.Classes; import org.scijava.discovery.Discoverer; +import org.scijava.discovery.Discovery; import org.scijava.ops.api.OpInfo; import org.scijava.ops.engine.yaml.YAMLOpInfoCreator; import org.slf4j.Logger; @@ -63,12 +65,13 @@ public class YAMLOpInfoDiscoverer implements Discoverer { private final Logger log = LoggerFactory.getLogger(getClass()); - private final List creators = Discoverer.using( - ServiceLoader::load).discover(YAMLOpInfoCreator.class); + private final List creators = Discoverer // + .usingProviders((Class c) -> ServiceLoader.load(c).stream()) // + .instances(YAMLOpInfoCreator.class); @SuppressWarnings("unchecked") @Override - public List discover(Class c) { + public List> discover(Class c) { // We only discover OpInfos if (!c.equals(OpInfo.class)) return Collections.emptyList(); // Parse each YAML file @@ -81,7 +84,10 @@ public List discover(Class c) { log.warn("Could not read Op YAML file " + opFile + ": ", e); } }); - return (List) opInfos; + // NB: the OpInfos are already built, so these discoveries are eager. + return opInfos.stream() // + .map(info -> Discovery.of((U) info)) // + .collect(Collectors.toList()); } /** diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpMethodInfo.java b/ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpMethodInfo.java similarity index 100% rename from scijava-ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpMethodInfo.java rename to ops-engine/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpMethodInfo.java diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.discovery.Discoverer b/ops-engine/src/main/resources/META-INF/services/org.scijava.discovery.Discoverer similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.discovery.Discoverer rename to ops-engine/src/main/resources/META-INF/services/org.scijava.discovery.Discoverer diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpEnvironment b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpEnvironment similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpEnvironment rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpEnvironment diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpHistory b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpHistory similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpHistory rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.api.OpHistory diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.InfoTreeGenerator b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.InfoTreeGenerator similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.InfoTreeGenerator rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.InfoTreeGenerator diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpDescriptionGenerator b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpDescriptionGenerator similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpDescriptionGenerator rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpDescriptionGenerator diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpInfoGenerator b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpInfoGenerator similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpInfoGenerator rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpInfoGenerator diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpWrapper b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpWrapper similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpWrapper rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.OpWrapper diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.MatchingRoutine b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.MatchingRoutine similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.MatchingRoutine rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.MatchingRoutine diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.reduce.InfoReducer b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.reduce.InfoReducer similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.reduce.InfoReducer rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.reduce.InfoReducer diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.yaml.YAMLOpInfoCreator b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.yaml.YAMLOpInfoCreator similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.yaml.YAMLOpInfoCreator rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.engine.yaml.YAMLOpInfoCreator diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.Op b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.Op similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.Op rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.Op diff --git a/scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection b/ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection similarity index 100% rename from scijava-ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection rename to ops-engine/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java b/ops-engine/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java similarity index 97% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java rename to ops-engine/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java index 60e715ad4..11e78ed81 100644 --- a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java +++ b/ops-engine/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java @@ -78,7 +78,7 @@ protected static Object[] objsFromNoArgConstructors(Class[] arr) { } protected static OpEnvironment barebonesEnvironment() { - Discoverer serviceLoading = Discoverer.using(ServiceLoader::load).except( // + Discoverer serviceLoading = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()).except( // Op.class, // OpInfo.class, // OpCollection.class // diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeGeneratorTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeGeneratorTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeGeneratorTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeGeneratorTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/InfoTreeTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderNoOutputTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderNoOutputTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderNoOutputTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderNoOutputTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTestOps.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTestOps.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTestOps.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpBuilderTestOps.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpEnvironmentTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpEnvironmentTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpEnvironmentTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpEnvironmentTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpInfoTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpInfoTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpInfoTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpInfoTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodBoxTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpMethodBoxTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodBoxTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpMethodBoxTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodInParentTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpMethodInParentTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodInParentTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpMethodInParentTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTestOps.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTestOps.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTestOps.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpMethodTestOps.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpPriorityTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/OpPriorityTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/OpPriorityTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/OpPriorityTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/TestOps.java b/ops-engine/src/test/java/org/scijava/ops/engine/TestOps.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/TestOps.java rename to ops-engine/src/test/java/org/scijava/ops/engine/TestOps.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/TestUtils.java b/ops-engine/src/test/java/org/scijava/ops/engine/TestUtils.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/TestUtils.java rename to ops-engine/src/test/java/org/scijava/ops/engine/TestUtils.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/AdaptationTypeVariableCaptureTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/AdaptationTypeVariableCaptureTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/AdaptationTypeVariableCaptureTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/AdaptationTypeVariableCaptureTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/OpAdaptationPriorityTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/OpAdaptationPriorityTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/OpAdaptationPriorityTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/OpAdaptationPriorityTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionObjectOutputTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionObjectOutputTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionObjectOutputTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionObjectOutputTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/PermutedComputerToFunctionAdaptTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/PermutedComputerToFunctionAdaptTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/PermutedComputerToFunctionAdaptTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/functional/PermutedComputerToFunctionAdaptTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/copy/CopyOpCollectionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/copy/CopyOpCollectionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/copy/CopyOpCollectionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/copy/CopyOpCollectionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/describe/BaseDescriptorsTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/describe/BaseDescriptorsTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/describe/BaseDescriptorsTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/describe/BaseDescriptorsTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/eval/EvalTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/eval/EvalTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/eval/EvalTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/eval/EvalTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/hints/AdaptationHintTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/hints/AdaptationHintTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/hints/AdaptationHintTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/hints/AdaptationHintTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/hints/ConversionHintTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/hints/ConversionHintTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/hints/ConversionHintTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/hints/ConversionHintTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGeneratorTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGeneratorTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGeneratorTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGeneratorTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java similarity index 98% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java index 52ae658f4..b93a242f4 100644 --- a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java +++ b/ops-engine/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java @@ -34,7 +34,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; import org.scijava.types.Nil; public class LambdaTypeBakerTest { diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java similarity index 98% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java index a26bab1c4..3280997c0 100644 --- a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java +++ b/ops-engine/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java @@ -60,7 +60,7 @@ public class OpCachingTest implements OpCollection { @BeforeEach public void setUp() { - Discoverer serviceLoading = Discoverer.using(ServiceLoader::load) // + Discoverer serviceLoading = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()) // .onlyFor( // OpWrapper.class, // MatchingRoutine.class, // diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/OpMethodDependencyPositionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/OpMethodDependencyPositionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/OpMethodDependencyPositionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/OpMethodDependencyPositionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/ProvenanceTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/ProvenanceTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/ProvenanceTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/ProvenanceTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java similarity index 89% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java index c734460b1..3df8d5d80 100644 --- a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java +++ b/ops-engine/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java @@ -45,8 +45,8 @@ public class ServiceLoaderDiscoveryIntegrationTest { @Test public void testOpDiscoveryRegressionIT() { - final Discoverer d = Discoverer.using(ServiceLoader::load); - final List discoveries = d.discover(Op.class); + final Discoverer d = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()); + final List discoveries = d.instances(Op.class); Assertions.assertEquals(237, discoveries.size()); @SuppressWarnings("unused") @@ -59,8 +59,8 @@ public void testOpDiscoveryRegressionIT() { @Test public void testOpCollectionDiscoveryRegressionIT() { - final Discoverer d = Discoverer.using(ServiceLoader::load); - final List discoveries = d.discover(OpCollection.class); + final Discoverer d = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()); + final List discoveries = d.instances(OpCollection.class); Assertions.assertEquals(22, discoveries.size()); @SuppressWarnings("unused") final OpInfoGenerator g = new OpCollectionInfoGenerator(); diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/UnnamedOpExceptionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/impl/UnnamedOpExceptionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/impl/UnnamedOpExceptionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/impl/UnnamedOpExceptionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithAnyTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithAnyTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithAnyTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithAnyTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithGCSTTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithGCSTTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithGCSTTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/MatchingWithGCSTTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpMatchingExceptionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpMatchingExceptionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpMatchingExceptionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpMatchingExceptionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java similarity index 99% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java index 2361a3b69..f91d39a5d 100644 --- a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java +++ b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java @@ -47,7 +47,7 @@ import org.scijava.ops.api.OpBuilder; import org.scijava.ops.engine.AbstractTestEnvironment; import org.scijava.ops.engine.OpBuilderTestOps; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; import org.scijava.types.Nil; /** diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/RawTypeMatchingTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/RawTypeMatchingTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/RawTypeMatchingTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/RawTypeMatchingTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfoTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfoTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfoTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfoTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionAdaptationTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionAdaptationTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionAdaptationTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionAdaptationTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionIOTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionIOTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionIOTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionIOTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPathwayIgnoreTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPathwayIgnoreTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPathwayIgnoreTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPathwayIgnoreTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPriorityTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPriorityTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPriorityTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPriorityTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverterTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverterTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverterTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverterTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveListConverterTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveListConverterTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveListConverterTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveListConverterTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfoTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfoTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfoTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfoTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/MatchingUtilsTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/MatchingUtilsTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/MatchingUtilsTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/MatchingUtilsTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpClassInfoTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpClassInfoTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpClassInfoTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpClassInfoTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpDescriptionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpDescriptionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpDescriptionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpDescriptionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpMethodInfoTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpMethodInfoTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpMethodInfoTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/matcher/impl/OpMethodInfoTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/math/MathOpCollectionTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/math/MathOpCollectionTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/math/MathOpCollectionTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/math/MathOpCollectionTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/BiFunctionWithNullable.java b/ops-engine/src/test/java/org/scijava/ops/engine/reduce/BiFunctionWithNullable.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/BiFunctionWithNullable.java rename to ops-engine/src/test/java/org/scijava/ops/engine/reduce/BiFunctionWithNullable.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsFromIFaceTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsFromIFaceTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsFromIFaceTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsFromIFaceTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/ReductionWithDependenciesTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/reduce/ReductionWithDependenciesTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/ReductionWithDependenciesTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/reduce/ReductionWithDependenciesTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableArg.java b/ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableArg.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableArg.java rename to ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableArg.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableFromIFace.java b/ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableFromIFace.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableFromIFace.java rename to ops-engine/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableFromIFace.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/stats/MeanTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/stats/MeanTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/stats/MeanTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/stats/MeanTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/stats/SizeTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/stats/SizeTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/stats/SizeTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/stats/SizeTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/ComputersTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/ComputersTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/ComputersTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/ComputersTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/FunctionsTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/FunctionsTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/FunctionsTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/FunctionsTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/InfosTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/InfosTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/InfosTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/InfosTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/InplacesTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/InplacesTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/InplacesTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/InplacesTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/LambdaizerTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/LambdaizerTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/LambdaizerTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/LambdaizerTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/LiftTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/LiftTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/LiftTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/LiftTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/OpsAsParametersTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/util/OpsAsParametersTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/util/OpsAsParametersTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/util/OpsAsParametersTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/YAMLOpTest.java b/ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/YAMLOpTest.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/YAMLOpTest.java rename to ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/YAMLOpTest.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLClassOp.java b/ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLClassOp.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLClassOp.java rename to ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLClassOp.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLFieldOp.java b/ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLFieldOp.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLFieldOp.java rename to ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLFieldOp.java diff --git a/scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLMethodOp.java b/ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLMethodOp.java similarity index 100% rename from scijava-ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLMethodOp.java rename to ops-engine/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLMethodOp.java diff --git a/scijava-ops-engine/templates/main/java/module-info.list b/ops-engine/templates/main/java/module-info.list similarity index 100% rename from scijava-ops-engine/templates/main/java/module-info.list rename to ops-engine/templates/main/java/module-info.list diff --git a/scijava-ops-engine/templates/main/java/module-info.vm b/ops-engine/templates/main/java/module-info.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/module-info.vm rename to ops-engine/templates/main/java/module-info.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.list b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.list b/ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.list b/ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.list diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.vm b/ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.vm similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.vm rename to ops-engine/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.vm diff --git a/scijava-ops-engine/templates/main/java/org/scijava/ops/engine/util/Globals.list b/ops-engine/templates/main/java/org/scijava/ops/engine/util/Globals.list similarity index 100% rename from scijava-ops-engine/templates/main/java/org/scijava/ops/engine/util/Globals.list rename to ops-engine/templates/main/java/org/scijava/ops/engine/util/Globals.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.list b/ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.list b/ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.vm similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.vm diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.list b/ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.list similarity index 100% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.list rename to ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.list diff --git a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm b/ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm similarity index 98% rename from scijava-ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm rename to ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm index a748dafc4..ad1337594 100644 --- a/scijava-ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm +++ b/ops-engine/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm @@ -19,7 +19,7 @@ import org.scijava.function.Producer; import org.scijava.ops.api.OpBuilder; import org.scijava.ops.engine.AbstractTestEnvironment; import org.scijava.ops.engine.OpBuilderTestOps; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; import org.scijava.types.Nil; /** diff --git a/scijava-ops-ext-parser/.gitignore b/ops-ext-parser/.gitignore similarity index 100% rename from scijava-ops-ext-parser/.gitignore rename to ops-ext-parser/.gitignore diff --git a/scijava-ops-ext-parser/LICENSE.txt b/ops-ext-parser/LICENSE.txt similarity index 100% rename from scijava-ops-ext-parser/LICENSE.txt rename to ops-ext-parser/LICENSE.txt diff --git a/scijava-ops-ext-parser/NOTICE.txt b/ops-ext-parser/NOTICE.txt similarity index 100% rename from scijava-ops-ext-parser/NOTICE.txt rename to ops-ext-parser/NOTICE.txt diff --git a/scijava-ops-ext-parser/README.md b/ops-ext-parser/README.md similarity index 100% rename from scijava-ops-ext-parser/README.md rename to ops-ext-parser/README.md diff --git a/scijava-ops-ext-parser/pom.xml b/ops-ext-parser/pom.xml similarity index 97% rename from scijava-ops-ext-parser/pom.xml rename to ops-ext-parser/pom.xml index b4772a1c0..d5beba111 100644 --- a/scijava-ops-ext-parser/pom.xml +++ b/ops-ext-parser/pom.xml @@ -101,7 +101,9 @@ true + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT diff --git a/scijava-ops-ext-parser/src/main/java/module-info.java b/ops-ext-parser/src/main/java/module-info.java similarity index 100% rename from scijava-ops-ext-parser/src/main/java/module-info.java rename to ops-ext-parser/src/main/java/module-info.java diff --git a/scijava-ops-ext-parser/src/main/java/org/scijava/ops/parser/InvalidOpException.java b/ops-ext-parser/src/main/java/org/scijava/ops/parser/InvalidOpException.java similarity index 100% rename from scijava-ops-ext-parser/src/main/java/org/scijava/ops/parser/InvalidOpException.java rename to ops-ext-parser/src/main/java/org/scijava/ops/parser/InvalidOpException.java diff --git a/scijava-ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParameter.java b/ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParameter.java similarity index 100% rename from scijava-ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParameter.java rename to ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParameter.java diff --git a/scijava-ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParser.java b/ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParser.java similarity index 100% rename from scijava-ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParser.java rename to ops-ext-parser/src/main/java/org/scijava/ops/parser/OpParser.java diff --git a/scijava-ops-ext-parser/src/test/java/org/scijava/ops/parser/TestOpParser.java b/ops-ext-parser/src/test/java/org/scijava/ops/parser/TestOpParser.java similarity index 100% rename from scijava-ops-ext-parser/src/test/java/org/scijava/ops/parser/TestOpParser.java rename to ops-ext-parser/src/test/java/org/scijava/ops/parser/TestOpParser.java diff --git a/scijava-ops-ext-parser/src/test/resources/test-ops.yaml b/ops-ext-parser/src/test/resources/test-ops.yaml similarity index 100% rename from scijava-ops-ext-parser/src/test/resources/test-ops.yaml rename to ops-ext-parser/src/test/resources/test-ops.yaml diff --git a/scijava-ops-flim/LICENSE.txt b/ops-flim/LICENSE.txt similarity index 100% rename from scijava-ops-flim/LICENSE.txt rename to ops-flim/LICENSE.txt diff --git a/scijava-ops-flim/README.md b/ops-flim/README.md similarity index 100% rename from scijava-ops-flim/README.md rename to ops-flim/README.md diff --git a/scijava-ops-flim/environment.yml b/ops-flim/environment.yml similarity index 100% rename from scijava-ops-flim/environment.yml rename to ops-flim/environment.yml diff --git a/scijava-ops-flim/images/example A.png b/ops-flim/images/example A.png similarity index 100% rename from scijava-ops-flim/images/example A.png rename to ops-flim/images/example A.png diff --git a/scijava-ops-flim/images/example tau.png b/ops-flim/images/example tau.png similarity index 100% rename from scijava-ops-flim/images/example tau.png rename to ops-flim/images/example tau.png diff --git a/scijava-ops-flim/images/example z.png b/ops-flim/images/example z.png similarity index 100% rename from scijava-ops-flim/images/example z.png rename to ops-flim/images/example z.png diff --git a/scijava-ops-flim/notebooks/Demo.ipynb b/ops-flim/notebooks/Demo.ipynb similarity index 100% rename from scijava-ops-flim/notebooks/Demo.ipynb rename to ops-flim/notebooks/Demo.ipynb diff --git a/scijava-ops-flim/notebooks/README.md b/ops-flim/notebooks/README.md similarity index 100% rename from scijava-ops-flim/notebooks/README.md rename to ops-flim/notebooks/README.md diff --git a/scijava-ops-flim/pom.xml b/ops-flim/pom.xml similarity index 95% rename from scijava-ops-flim/pom.xml rename to ops-flim/pom.xml index 09b7e233e..422be54d4 100644 --- a/scijava-ops-flim/pom.xml +++ b/ops-flim/pom.xml @@ -96,13 +96,20 @@ SciJava developers. + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 2.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/AbstractFitRAI.java b/ops-flim/src/main/java/org/scijava/ops/flim/AbstractFitRAI.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/AbstractFitRAI.java rename to ops-flim/src/main/java/org/scijava/ops/flim/AbstractFitRAI.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/AmplitudeFractions.java b/ops-flim/src/main/java/org/scijava/ops/flim/AmplitudeFractions.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/AmplitudeFractions.java rename to ops-flim/src/main/java/org/scijava/ops/flim/AmplitudeFractions.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/FitParams.java b/ops-flim/src/main/java/org/scijava/ops/flim/FitParams.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/FitParams.java rename to ops-flim/src/main/java/org/scijava/ops/flim/FitParams.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/FitResults.java b/ops-flim/src/main/java/org/scijava/ops/flim/FitResults.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/FitResults.java rename to ops-flim/src/main/java/org/scijava/ops/flim/FitResults.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/FitWorker.java b/ops-flim/src/main/java/org/scijava/ops/flim/FitWorker.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/FitWorker.java rename to ops-flim/src/main/java/org/scijava/ops/flim/FitWorker.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/MeanLifetimes.java b/ops-flim/src/main/java/org/scijava/ops/flim/MeanLifetimes.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/MeanLifetimes.java rename to ops-flim/src/main/java/org/scijava/ops/flim/MeanLifetimes.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/ParamEstimator.java b/ops-flim/src/main/java/org/scijava/ops/flim/ParamEstimator.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/ParamEstimator.java rename to ops-flim/src/main/java/org/scijava/ops/flim/ParamEstimator.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/Pseudocolor.java b/ops-flim/src/main/java/org/scijava/ops/flim/Pseudocolor.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/Pseudocolor.java rename to ops-flim/src/main/java/org/scijava/ops/flim/Pseudocolor.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractFitWorker.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractFitWorker.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractFitWorker.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractFitWorker.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractSingleFitWorker.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractSingleFitWorker.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractSingleFitWorker.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/AbstractSingleFitWorker.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/BayesFit.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/BayesFit.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/BayesFit.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/BayesFit.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/Creators.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/Creators.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/Creators.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/Creators.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/GlobalFit.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/GlobalFit.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/GlobalFit.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/GlobalFit.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/LMAFit.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/LMAFit.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/LMAFit.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/LMAFit.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/PhasorFit.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/PhasorFit.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/PhasorFit.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/PhasorFit.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/RLDFit.java b/ops-flim/src/main/java/org/scijava/ops/flim/impl/RLDFit.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/impl/RLDFit.java rename to ops-flim/src/main/java/org/scijava/ops/flim/impl/RLDFit.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/types/FitParamsTypeExtractor.java b/ops-flim/src/main/java/org/scijava/ops/flim/types/FitParamsTypeExtractor.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/types/FitParamsTypeExtractor.java rename to ops-flim/src/main/java/org/scijava/ops/flim/types/FitParamsTypeExtractor.java diff --git a/scijava-ops-flim/src/main/java/org/scijava/ops/flim/util/RAHelper.java b/ops-flim/src/main/java/org/scijava/ops/flim/util/RAHelper.java similarity index 100% rename from scijava-ops-flim/src/main/java/org/scijava/ops/flim/util/RAHelper.java rename to ops-flim/src/main/java/org/scijava/ops/flim/util/RAHelper.java diff --git a/scijava-ops-flim/src/main/resources/META-INF/services/org.scijava.types.TypeExtractor b/ops-flim/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor similarity index 100% rename from scijava-ops-flim/src/main/resources/META-INF/services/org.scijava.types.TypeExtractor rename to ops-flim/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor diff --git a/scijava-ops-flim/src/test/java/org/scijava/ops/flim/AbstractFlimTest.java b/ops-flim/src/test/java/org/scijava/ops/flim/AbstractFlimTest.java similarity index 100% rename from scijava-ops-flim/src/test/java/org/scijava/ops/flim/AbstractFlimTest.java rename to ops-flim/src/test/java/org/scijava/ops/flim/AbstractFlimTest.java diff --git a/scijava-ops-flim/src/test/java/org/scijava/ops/flim/AmplitudeFractionsTest.java b/ops-flim/src/test/java/org/scijava/ops/flim/AmplitudeFractionsTest.java similarity index 100% rename from scijava-ops-flim/src/test/java/org/scijava/ops/flim/AmplitudeFractionsTest.java rename to ops-flim/src/test/java/org/scijava/ops/flim/AmplitudeFractionsTest.java diff --git a/scijava-ops-flim/src/test/java/org/scijava/ops/flim/FitTest.java b/ops-flim/src/test/java/org/scijava/ops/flim/FitTest.java similarity index 100% rename from scijava-ops-flim/src/test/java/org/scijava/ops/flim/FitTest.java rename to ops-flim/src/test/java/org/scijava/ops/flim/FitTest.java diff --git a/scijava-ops-flim/src/test/java/org/scijava/ops/flim/MeanLifetimesTest.java b/ops-flim/src/test/java/org/scijava/ops/flim/MeanLifetimesTest.java similarity index 100% rename from scijava-ops-flim/src/test/java/org/scijava/ops/flim/MeanLifetimesTest.java rename to ops-flim/src/test/java/org/scijava/ops/flim/MeanLifetimesTest.java diff --git a/scijava-ops-flim/src/test/java/org/scijava/ops/flim/MiscTest.java b/ops-flim/src/test/java/org/scijava/ops/flim/MiscTest.java similarity index 100% rename from scijava-ops-flim/src/test/java/org/scijava/ops/flim/MiscTest.java rename to ops-flim/src/test/java/org/scijava/ops/flim/MiscTest.java diff --git a/scijava-ops-flim/test_files/cells.sdt b/ops-flim/test_files/cells.sdt similarity index 100% rename from scijava-ops-flim/test_files/cells.sdt rename to ops-flim/test_files/cells.sdt diff --git a/scijava-ops-flim/test_files/input.sdt b/ops-flim/test_files/input.sdt similarity index 100% rename from scijava-ops-flim/test_files/input.sdt rename to ops-flim/test_files/input.sdt diff --git a/scijava-ops-flim/test_files/test2.sdt b/ops-flim/test_files/test2.sdt similarity index 100% rename from scijava-ops-flim/test_files/test2.sdt rename to ops-flim/test_files/test2.sdt diff --git a/scijava-ops-image/LICENSE.txt b/ops-image/LICENSE.txt similarity index 100% rename from scijava-ops-image/LICENSE.txt rename to ops-image/LICENSE.txt diff --git a/scijava-ops-image/README.md b/ops-image/README.md similarity index 97% rename from scijava-ops-image/README.md rename to ops-image/README.md index 8dc2c8e3f..a987ffdf5 100644 --- a/scijava-ops-image/README.md +++ b/ops-image/README.md @@ -7,7 +7,6 @@ This library provides a suite of algorithms to aid in image processing using Sci org.scijava scijava-ops-image 1.0.0 - test ``` diff --git a/scijava-ops-image/bin/generate.groovy b/ops-image/bin/generate.groovy similarity index 100% rename from scijava-ops-image/bin/generate.groovy rename to ops-image/bin/generate.groovy diff --git a/scijava-ops-image/pom.xml b/ops-image/pom.xml similarity index 99% rename from scijava-ops-image/pom.xml rename to ops-image/pom.xml index 8a0492b94..b1851f47b 100644 --- a/scijava-ops-image/pom.xml +++ b/ops-image/pom.xml @@ -249,6 +249,7 @@ 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT @@ -257,6 +258,7 @@ 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT diff --git a/scijava-ops-image/src/main/java/module-info.java b/ops-image/src/main/java/module-info.java similarity index 100% rename from scijava-ops-image/src/main/java/module-info.java rename to ops-image/src/main/java/module-info.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/About.java b/ops-image/src/main/java/org/scijava/ops/image/About.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/About.java rename to ops-image/src/main/java/org/scijava/ops/image/About.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/LiftNeighborhoodComputersToRAI.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/LiftNeighborhoodComputersToRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/LiftNeighborhoodComputersToRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/LiftNeighborhoodComputersToRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/RAIToIIOps.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/RAIToIIOps.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/RAIToIIOps.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/RAIToIIOps.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/LiftNeighborhoodComputersToFunctionsOnImgs.java b/ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/LiftNeighborhoodComputersToFunctionsOnImgs.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/LiftNeighborhoodComputersToFunctionsOnImgs.java rename to ops-image/src/main/java/org/scijava/ops/image/adapt/complexLift/LiftNeighborhoodComputersToFunctionsOnImgs.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ColocUtil.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/ColocUtil.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ColocUtil.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/ColocUtil.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/IntArraySorter.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/IntArraySorter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/IntArraySorter.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/IntArraySorter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/IntComparator.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/IntComparator.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/IntComparator.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/IntComparator.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/MergeSort.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/MergeSort.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/MergeSort.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/MergeSort.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/icq/LiICQ.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/icq/LiICQ.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/icq/LiICQ.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/icq/LiICQ.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRank.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRank.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRank.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRank.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKT.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKT.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKT.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKT.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/PValueResult.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/PValueResult.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/PValueResult.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/pValue/PValueResult.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsons.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsons.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsons.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsons.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/AdaptiveSmoothedKendallTau.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/saca/AdaptiveSmoothedKendallTau.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/AdaptiveSmoothedKendallTau.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/saca/AdaptiveSmoothedKendallTau.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACAHeatmapZScore.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACAHeatmapZScore.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACAHeatmapZScore.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACAHeatmapZScore.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACASigMask.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACASigMask.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACASigMask.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/saca/SACASigMask.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/WtKendallTau.java b/ops-image/src/main/java/org/scijava/ops/image/coloc/saca/WtKendallTau.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/coloc/saca/WtKendallTau.java rename to ops-image/src/main/java/org/scijava/ops/image/coloc/saca/WtKendallTau.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/ConvertComplexTypes.java b/ops-image/src/main/java/org/scijava/ops/image/convert/ConvertComplexTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/ConvertComplexTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/ConvertComplexTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/ConvertImages.java b/ops-image/src/main/java/org/scijava/ops/image/convert/ConvertImages.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/ConvertImages.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/ConvertImages.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/ConvertNumbersToRealTypes.java b/ops-image/src/main/java/org/scijava/ops/image/convert/ConvertNumbersToRealTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/ConvertNumbersToRealTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/ConvertNumbersToRealTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/Converters.java b/ops-image/src/main/java/org/scijava/ops/image/convert/Converters.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/Converters.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/Converters.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/Types.java b/ops-image/src/main/java/org/scijava/ops/image/convert/Types.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/Types.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/Types.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/clip/ClipRealTypes.java b/ops-image/src/main/java/org/scijava/ops/image/convert/clip/ClipRealTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/clip/ClipRealTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/clip/ClipRealTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/convert/copy/CopyComplexTypes.java b/ops-image/src/main/java/org/scijava/ops/image/convert/copy/CopyComplexTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/convert/copy/CopyComplexTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/convert/copy/CopyComplexTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/copy/Copiers.java b/ops-image/src/main/java/org/scijava/ops/image/copy/Copiers.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/copy/Copiers.java rename to ops-image/src/main/java/org/scijava/ops/image/copy/Copiers.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/CreateTypes.java b/ops-image/src/main/java/org/scijava/ops/image/create/CreateTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/CreateTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/create/CreateTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/Creators.java b/ops-image/src/main/java/org/scijava/ops/image/create/Creators.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/Creators.java rename to ops-image/src/main/java/org/scijava/ops/image/create/Creators.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernel2ndDerivBiGauss.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernel2ndDerivBiGauss.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernel2ndDerivBiGauss.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernel2ndDerivBiGauss.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelBiGauss.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelBiGauss.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelBiGauss.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelBiGauss.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGabor.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGabor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGabor.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGabor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGauss.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGauss.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGauss.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGauss.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGibsonLanni.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGibsonLanni.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGibsonLanni.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGibsonLanni.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelLog.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelLog.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelLog.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelLog.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelSobel.java b/ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelSobel.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelSobel.java rename to ops-image/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelSobel.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/create/Imgs.java b/ops-image/src/main/java/org/scijava/ops/image/create/Imgs.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/create/Imgs.java rename to ops-image/src/main/java/org/scijava/ops/image/create/Imgs.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/AccelerationState.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/AccelerationState.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/AccelerationState.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/AccelerationState.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantFirstGuess.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantFirstGuess.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantFirstGuess.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantFirstGuess.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantNormalizationFactor.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantNormalizationFactor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantNormalizationFactor.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantNormalizationFactor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucy.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucy.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucyTV.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucyTV.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucyTV.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucyTV.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyC.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyC.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyC.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyC.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyCorrection.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyCorrection.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyCorrection.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyCorrection.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyTVUpdate.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyTVUpdate.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyTVUpdate.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyTVUpdate.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyUpdate.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyUpdate.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyUpdate.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyUpdate.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/VectorAccelerator.java b/ops-image/src/main/java/org/scijava/ops/image/deconvolve/VectorAccelerator.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/deconvolve/VectorAccelerator.java rename to ops-image/src/main/java/org/scijava/ops/image/deconvolve/VectorAccelerator.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/describe/ImgLib2Descriptors.java b/ops-image/src/main/java/org/scijava/ops/image/describe/ImgLib2Descriptors.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/describe/ImgLib2Descriptors.java rename to ops-image/src/main/java/org/scijava/ops/image/describe/ImgLib2Descriptors.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/AbstractHaralickFeature.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/AbstractHaralickFeature.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/AbstractHaralickFeature.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/AbstractHaralickFeature.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultASM.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultASM.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultASM.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultASM.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterProminence.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterProminence.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterProminence.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterProminence.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterShade.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterShade.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterShade.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterShade.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultContrast.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultContrast.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultContrast.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultContrast.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultCorrelation.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultCorrelation.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultCorrelation.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultCorrelation.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceEntropy.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceEntropy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceEntropy.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceEntropy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceVariance.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceVariance.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceVariance.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceVariance.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultEntropy.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultEntropy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultEntropy.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultEntropy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM1.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM1.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM1.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM1.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM2.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM2.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM2.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM2.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultIFDM.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultIFDM.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultIFDM.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultIFDM.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultMaxProbability.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultMaxProbability.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultMaxProbability.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultMaxProbability.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumAverage.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumAverage.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumAverage.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumAverage.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumEntropy.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumEntropy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumEntropy.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumEntropy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumVariance.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumVariance.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumVariance.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumVariance.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultTextureHomogeneity.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultTextureHomogeneity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultTextureHomogeneity.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultTextureHomogeneity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultVariance.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultVariance.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultVariance.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/DefaultVariance.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocHXY.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocHXY.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocHXY.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocHXY.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanX.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanX.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanX.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanX.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanY.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanY.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanY.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanY.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPX.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPX.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPX.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPX.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXMinusY.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXMinusY.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXMinusY.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXMinusY.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXPlusY.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXPlusY.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXPlusY.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXPlusY.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPY.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPY.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPY.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPY.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdX.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdX.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdX.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdX.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdY.java b/ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdY.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdY.java rename to ops-image/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdY.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2D.java b/ops-image/src/main/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2D.java rename to ops-image/src/main/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/lbp2d/DefaultLBP2D.java b/ops-image/src/main/java/org/scijava/ops/image/features/lbp2d/DefaultLBP2D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/lbp2d/DefaultLBP2D.java rename to ops-image/src/main/java/org/scijava/ops/image/features/lbp2d/DefaultLBP2D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultCoarsenessFeature.java b/ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultCoarsenessFeature.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultCoarsenessFeature.java rename to ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultCoarsenessFeature.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultContrastFeature.java b/ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultContrastFeature.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultContrastFeature.java rename to ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultContrastFeature.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultDirectionalityFeature.java b/ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultDirectionalityFeature.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultDirectionalityFeature.java rename to ops-image/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultDirectionalityFeature.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultMagnitudeFeature.java b/ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultMagnitudeFeature.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultMagnitudeFeature.java rename to ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultMagnitudeFeature.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultPhaseFeature.java b/ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultPhaseFeature.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultPhaseFeature.java rename to ops-image/src/main/java/org/scijava/ops/image/features/zernike/DefaultPhaseFeature.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/Polynom.java b/ops-image/src/main/java/org/scijava/ops/image/features/zernike/Polynom.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/Polynom.java rename to ops-image/src/main/java/org/scijava/ops/image/features/zernike/Polynom.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeComputer.java b/ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeComputer.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeComputer.java rename to ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeComputer.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeMoment.java b/ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeMoment.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeMoment.java rename to ops-image/src/main/java/org/scijava/ops/image/features/zernike/ZernikeMoment.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/CenterAwareNeighborhoodBasedFilter.java b/ops-image/src/main/java/org/scijava/ops/image/filter/CenterAwareNeighborhoodBasedFilter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/CenterAwareNeighborhoodBasedFilter.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/CenterAwareNeighborhoodBasedFilter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/FFTMethodsLinearFFTFilterC.java b/ops-image/src/main/java/org/scijava/ops/image/filter/FFTMethodsLinearFFTFilterC.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/FFTMethodsLinearFFTFilterC.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/FFTMethodsLinearFFTFilterC.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/NeighborhoodFilters.java b/ops-image/src/main/java/org/scijava/ops/image/filter/NeighborhoodFilters.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/NeighborhoodFilters.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/NeighborhoodFilters.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/addNoise/NoiseAdders.java b/ops-image/src/main/java/org/scijava/ops/image/filter/addNoise/NoiseAdders.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/addNoise/NoiseAdders.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/addNoise/NoiseAdders.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/bilateral/DefaultBilateral.java b/ops-image/src/main/java/org/scijava/ops/image/filter/bilateral/DefaultBilateral.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/bilateral/DefaultBilateral.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/bilateral/DefaultBilateral.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTC.java b/ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTC.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTC.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTC.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTF.java b/ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTF.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTF.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTF.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolve/NaiveConvolutions.java b/ops-image/src/main/java/org/scijava/ops/image/filter/convolve/NaiveConvolutions.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/convolve/NaiveConvolutions.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/convolve/NaiveConvolutions.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTC.java b/ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTC.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTC.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTC.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTF.java b/ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTF.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTF.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTF.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativeRAI.java b/ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativeRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativeRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativeRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativesRAI.java b/ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativesRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativesRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativesRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGauss.java b/ops-image/src/main/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGauss.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGauss.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGauss.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/dog/DefaultDoG.java b/ops-image/src/main/java/org/scijava/ops/image/filter/dog/DefaultDoG.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/dog/DefaultDoG.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/dog/DefaultDoG.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/dog/DoGVaryingSigmas.java b/ops-image/src/main/java/org/scijava/ops/image/filter/dog/DoGVaryingSigmas.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/dog/DoGVaryingSigmas.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/dog/DoGVaryingSigmas.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fft/FFTs.java b/ops-image/src/main/java/org/scijava/ops/image/filter/fft/FFTs.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fft/FFTs.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/fft/FFTs.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTMethodsSize.java b/ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTMethodsSize.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTMethodsSize.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTMethodsSize.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTSize.java b/ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTSize.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTSize.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTSize.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/DefaultComputeFFTSize.java b/ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/DefaultComputeFFTSize.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/DefaultComputeFFTSize.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/DefaultComputeFFTSize.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextPowerOfTwo.java b/ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextPowerOfTwo.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextPowerOfTwo.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextPowerOfTwo.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumber.java b/ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumber.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumber.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumber.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/gauss/Gaussians.java b/ops-image/src/main/java/org/scijava/ops/image/filter/gauss/Gaussians.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/gauss/Gaussians.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/gauss/Gaussians.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/hessian/HessianRAI.java b/ops-image/src/main/java/org/scijava/ops/image/filter/hessian/HessianRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/hessian/HessianRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/hessian/HessianRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpC.java b/ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpC.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpC.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpC.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpI.java b/ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpI.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadInputFFT.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadInputFFT.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadInputFFT.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadInputFFT.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadShiftKernelFFT.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadShiftKernelFFT.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadShiftKernelFFT.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadShiftKernelFFT.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInput.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInput.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInput.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInput.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFT.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFT.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFT.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFT.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFTMethods.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFTMethods.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFTMethods.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFTMethods.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernel.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernel.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernel.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernel.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernelFFTMethods.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernelFFTMethods.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernelFFTMethods.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernelFFTMethods.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalCentered.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalCentered.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalCentered.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalCentered.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalOrigin.java b/ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalOrigin.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalOrigin.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalOrigin.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/sigma/DefaultSigmaFilter.java b/ops-image/src/main/java/org/scijava/ops/image/filter/sigma/DefaultSigmaFilter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/sigma/DefaultSigmaFilter.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/sigma/DefaultSigmaFilter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/sobel/SobelRAI.java b/ops-image/src/main/java/org/scijava/ops/image/filter/sobel/SobelRAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/sobel/SobelRAI.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/sobel/SobelRAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java b/ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/filter/vesselness/DefaultFrangi.java b/ops-image/src/main/java/org/scijava/ops/image/filter/vesselness/DefaultFrangi.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/filter/vesselness/DefaultFrangi.java rename to ops-image/src/main/java/org/scijava/ops/image/filter/vesselness/DefaultFrangi.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoundarySizeConvexHull.java b/ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoundarySizeConvexHull.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoundarySizeConvexHull.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoundarySizeConvexHull.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoxivity.java b/ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoxivity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoxivity.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/AbstractBoxivity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractConvexity.java b/ops-image/src/main/java/org/scijava/ops/image/geom/AbstractConvexity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractConvexity.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/AbstractConvexity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSizeConvexHull.java b/ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSizeConvexHull.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSizeConvexHull.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSizeConvexHull.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSolidity.java b/ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSolidity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSolidity.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/AbstractSolidity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/CentroidII.java b/ops-image/src/main/java/org/scijava/ops/image/geom/CentroidII.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/CentroidII.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/CentroidII.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/CentroidLabelRegion.java b/ops-image/src/main/java/org/scijava/ops/image/geom/CentroidLabelRegion.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/CentroidLabelRegion.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/CentroidLabelRegion.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/CentroidPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/CentroidPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/CentroidPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/CentroidPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/DefaultCenterOfGravity.java b/ops-image/src/main/java/org/scijava/ops/image/geom/DefaultCenterOfGravity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/DefaultCenterOfGravity.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/DefaultCenterOfGravity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/GeomUtils.java b/ops-image/src/main/java/org/scijava/ops/image/geom/GeomUtils.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/GeomUtils.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/GeomUtils.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/SizeII.java b/ops-image/src/main/java/org/scijava/ops/image/geom/SizeII.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/SizeII.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/SizeII.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundarySizeConvexHullPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundarySizeConvexHullPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundarySizeConvexHullPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundarySizeConvexHullPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundingBox.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundingBox.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundingBox.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundingBox.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoxivityPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoxivityPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoxivityPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoxivityPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultCircularity.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultCircularity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultCircularity.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultCircularity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultContour.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultContour.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultContour.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultContour.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexHull2D.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexHull2D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexHull2D.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexHull2D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexityPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexityPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexityPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexityPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultEccentricity.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultEccentricity.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultEccentricity.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultEccentricity.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultElongation.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultElongation.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultElongation.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultElongation.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsAngle.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsAngle.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsAngle.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsAngle.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameter.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameter.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameterForAngle.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameterForAngle.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameterForAngle.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameterForAngle.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMajorAxis.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMajorAxis.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMajorAxis.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMajorAxis.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeret.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeret.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeret.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeret.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretAngle.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretAngle.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretAngle.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretAngle.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretDiameter.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretDiameter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretDiameter.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretDiameter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeret.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeret.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeret.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeret.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretAngle.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretAngle.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretAngle.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretAngle.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretDiameter.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretDiameter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretDiameter.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretDiameter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorAxis.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorAxis.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorAxis.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorAxis.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultPerimeterLength.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultPerimeterLength.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultPerimeterLength.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultPerimeterLength.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultRoundness.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultRoundness.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultRoundness.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultRoundness.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizeConvexHullPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizeConvexHullPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizeConvexHullPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizeConvexHullPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizePolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizePolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizePolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizePolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSmallestEnclosingRectangle.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSmallestEnclosingRectangle.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSmallestEnclosingRectangle.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSmallestEnclosingRectangle.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSolidityPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSolidityPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSolidityPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSolidityPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountConvexHullPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountConvexHullPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountConvexHullPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountConvexHullPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountPolygon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountPolygon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountPolygon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountPolygon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Facet.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Facet.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Facet.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Facet.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Horizon.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Horizon.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Horizon.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Horizon.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/TriangularFacet.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/TriangularFacet.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/TriangularFacet.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/TriangularFacet.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/UpdateablePointSet.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/UpdateablePointSet.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/UpdateablePointSet.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/UpdateablePointSet.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Vertex.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Vertex.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Vertex.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/Vertex.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/VertexInterpolator.java b/ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/VertexInterpolator.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/VertexInterpolator.java rename to ops-image/src/main/java/org/scijava/ops/image/geom/geom3d/VertexInterpolator.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/ascii/DefaultASCII.java b/ops-image/src/main/java/org/scijava/ops/image/image/ascii/DefaultASCII.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/ascii/DefaultASCII.java rename to ops-image/src/main/java/org/scijava/ops/image/image/ascii/DefaultASCII.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix.java b/ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix.java rename to ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix2D.java b/ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix2D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix2D.java rename to ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix2D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix3D.java b/ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix3D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix3D.java rename to ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix3D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation.java b/ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation.java rename to ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation2D.java b/ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation2D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation2D.java rename to ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation2D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation3D.java b/ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation3D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation3D.java rename to ops-image/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation3D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/CalibratedDistanceTransformer.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/CalibratedDistanceTransformer.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/CalibratedDistanceTransformer.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/CalibratedDistanceTransformer.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransformer.java b/ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransformer.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransformer.java rename to ops-image/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransformer.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/fill/Fills.java b/ops-image/src/main/java/org/scijava/ops/image/image/fill/Fills.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/fill/Fills.java rename to ops-image/src/main/java/org/scijava/ops/image/image/fill/Fills.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/histogram/HistogramCreate.java b/ops-image/src/main/java/org/scijava/ops/image/image/histogram/HistogramCreate.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/histogram/HistogramCreate.java rename to ops-image/src/main/java/org/scijava/ops/image/image/histogram/HistogramCreate.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java b/ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java rename to ops-image/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/DefaultIntegralImg.java b/ops-image/src/main/java/org/scijava/ops/image/image/integral/DefaultIntegralImg.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/DefaultIntegralImg.java rename to ops-image/src/main/java/org/scijava/ops/image/image/integral/DefaultIntegralImg.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/IntegralCursor.java b/ops-image/src/main/java/org/scijava/ops/image/image/integral/IntegralCursor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/IntegralCursor.java rename to ops-image/src/main/java/org/scijava/ops/image/image/integral/IntegralCursor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/SquareIntegralImg.java b/ops-image/src/main/java/org/scijava/ops/image/image/integral/SquareIntegralImg.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/SquareIntegralImg.java rename to ops-image/src/main/java/org/scijava/ops/image/image/integral/SquareIntegralImg.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/WrappedIntegralImg.java b/ops-image/src/main/java/org/scijava/ops/image/image/integral/WrappedIntegralImg.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/integral/WrappedIntegralImg.java rename to ops-image/src/main/java/org/scijava/ops/image/image/integral/WrappedIntegralImg.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertII.java b/ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertII.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertII.java rename to ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertII.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertIIInteger.java b/ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertIIInteger.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertIIInteger.java rename to ops-image/src/main/java/org/scijava/ops/image/image/invert/InvertIIInteger.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/invert/Inverters.java b/ops-image/src/main/java/org/scijava/ops/image/image/invert/Inverters.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/invert/Inverters.java rename to ops-image/src/main/java/org/scijava/ops/image/image/invert/Inverters.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIComputer.java b/ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIComputer.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIComputer.java rename to ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIComputer.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIFunction.java b/ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazy.java b/ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazy.java rename to ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazyFunction.java b/ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazyFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazyFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazyFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeRealTypeComputer.java b/ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeRealTypeComputer.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeRealTypeComputer.java rename to ops-image/src/main/java/org/scijava/ops/image/image/normalize/NormalizeRealTypeComputer.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/Watershed.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/Watershed.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/Watershed.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/Watershed.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinary.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinary.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinary.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinary.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinaryFunction.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinaryFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinaryFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinaryFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigma.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigma.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigma.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigma.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaFunction.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedFunction.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeeded.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeeded.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeeded.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeeded.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeededFunction.java b/ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeededFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeededFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeededFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/AbstractImageMomentOp.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/AbstractImageMomentOp.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/AbstractImageMomentOp.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/AbstractImageMomentOp.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment00.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment00.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment00.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment00.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment01.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment01.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment01.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment01.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment02.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment02.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment02.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment02.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment03.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment03.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment03.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment03.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment10.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment10.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment10.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment10.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment11.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment11.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment11.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment11.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment12.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment12.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment12.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment12.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment20.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment20.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment20.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment20.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment21.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment21.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment21.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment21.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment30.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment30.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment30.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment30.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment00.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment00.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment00.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment00.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment11.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment11.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment11.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment11.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment1.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment1.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment1.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment1.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment2.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment2.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment2.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment2.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment3.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment3.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment3.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment3.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment4.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment4.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment4.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment4.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment5.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment5.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment5.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment5.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment6.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment6.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment6.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment6.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment7.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment7.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment7.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment7.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment00.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment00.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment00.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment00.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment01.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment01.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment01.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment01.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment10.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment10.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment10.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment10.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment11.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment11.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment11.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment11.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment02.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment02.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment02.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment02.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment03.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment03.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment03.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment03.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment11.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment11.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment11.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment11.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment12.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment12.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment12.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment12.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment20.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment20.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment20.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment20.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment21.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment21.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment21.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment21.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment30.java b/ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment30.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment30.java rename to ops-image/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment30.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/MergeLabeling.java b/ops-image/src/main/java/org/scijava/ops/image/labeling/MergeLabeling.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/MergeLabeling.java rename to ops-image/src/main/java/org/scijava/ops/image/labeling/MergeLabeling.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/cca/DefaultCCA.java b/ops-image/src/main/java/org/scijava/ops/image/labeling/cca/DefaultCCA.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/labeling/cca/DefaultCCA.java rename to ops-image/src/main/java/org/scijava/ops/image/labeling/cca/DefaultCCA.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/linalg/rotate/Rotations.java b/ops-image/src/main/java/org/scijava/ops/image/linalg/rotate/Rotations.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/linalg/rotate/Rotations.java rename to ops-image/src/main/java/org/scijava/ops/image/linalg/rotate/Rotations.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/logic/BooleanTypeLogic.java b/ops-image/src/main/java/org/scijava/ops/image/logic/BooleanTypeLogic.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/logic/BooleanTypeLogic.java rename to ops-image/src/main/java/org/scijava/ops/image/logic/BooleanTypeLogic.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/logic/Default.java b/ops-image/src/main/java/org/scijava/ops/image/logic/Default.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/logic/Default.java rename to ops-image/src/main/java/org/scijava/ops/image/logic/Default.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/logic/Ternary.java b/ops-image/src/main/java/org/scijava/ops/image/logic/Ternary.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/logic/Ternary.java rename to ops-image/src/main/java/org/scijava/ops/image/logic/Ternary.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/DefaultMapNeighborhood.java b/ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/DefaultMapNeighborhood.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/DefaultMapNeighborhood.java rename to ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/DefaultMapNeighborhood.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodWithCenter.java b/ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodWithCenter.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodWithCenter.java rename to ops-image/src/main/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodWithCenter.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/math/BinaryNumericTypeMath.java b/ops-image/src/main/java/org/scijava/ops/image/math/BinaryNumericTypeMath.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/math/BinaryNumericTypeMath.java rename to ops-image/src/main/java/org/scijava/ops/image/math/BinaryNumericTypeMath.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/math/BinaryRealTypeMath.java b/ops-image/src/main/java/org/scijava/ops/image/math/BinaryRealTypeMath.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/math/BinaryRealTypeMath.java rename to ops-image/src/main/java/org/scijava/ops/image/math/BinaryRealTypeMath.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/math/NullaryNumericTypeMath.java b/ops-image/src/main/java/org/scijava/ops/image/math/NullaryNumericTypeMath.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/math/NullaryNumericTypeMath.java rename to ops-image/src/main/java/org/scijava/ops/image/math/NullaryNumericTypeMath.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/math/UnaryRealTypeMath.java b/ops-image/src/main/java/org/scijava/ops/image/math/UnaryRealTypeMath.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/math/UnaryRealTypeMath.java rename to ops-image/src/main/java/org/scijava/ops/image/math/UnaryRealTypeMath.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/math/multiply/ComplexConjugateMultiplyOp.java b/ops-image/src/main/java/org/scijava/ops/image/math/multiply/ComplexConjugateMultiplyOp.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/math/multiply/ComplexConjugateMultiplyOp.java rename to ops-image/src/main/java/org/scijava/ops/image/math/multiply/ComplexConjugateMultiplyOp.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/BlackTopHats.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/BlackTopHats.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/BlackTopHats.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/BlackTopHats.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Closes.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/Closes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Closes.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/Closes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Dilations.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/Dilations.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Dilations.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/Dilations.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/DistanceTransforms.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/DistanceTransforms.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/DistanceTransforms.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/DistanceTransforms.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Erosions.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/Erosions.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Erosions.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/Erosions.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/ExtractHoles.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/ExtractHoles.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/ExtractHoles.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/ExtractHoles.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/FillHoles.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/FillHoles.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/FillHoles.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/FillHoles.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/FloodFills.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/FloodFills.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/FloodFills.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/FloodFills.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Opens.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/Opens.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Opens.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/Opens.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Outline.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/Outline.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Outline.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/Outline.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Table2Ds.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/Table2Ds.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/Table2Ds.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/Table2Ds.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/WhiteTopHats.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/WhiteTopHats.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/WhiteTopHats.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/WhiteTopHats.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/Abstract3x3NeighbourhoodThinning.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/Abstract3x3NeighbourhoodThinning.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/Abstract3x3NeighbourhoodThinning.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/Abstract3x3NeighbourhoodThinning.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/AbstractThin.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/AbstractThin.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/AbstractThin.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/AbstractThin.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/GuoHallThinningStrategy.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/GuoHallThinningStrategy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/GuoHallThinningStrategy.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/GuoHallThinningStrategy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/HilditchThinningStrategy.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/HilditchThinningStrategy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/HilditchThinningStrategy.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/HilditchThinningStrategy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/MorphologicalThinningStrategy.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/MorphologicalThinningStrategy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/MorphologicalThinningStrategy.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/MorphologicalThinningStrategy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinGuoHall.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinGuoHall.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinGuoHall.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinGuoHall.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinHilditch.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinHilditch.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinHilditch.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinHilditch.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinMorphological.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinMorphological.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinMorphological.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinMorphological.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinZhangSuen.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinZhangSuen.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinZhangSuen.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinZhangSuen.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinningStrategy.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinningStrategy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinningStrategy.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ThinningStrategy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ZhangSuenThinningStrategy.java b/ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ZhangSuenThinningStrategy.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ZhangSuenThinningStrategy.java rename to ops-image/src/main/java/org/scijava/ops/image/morphology/thin/ZhangSuenThinningStrategy.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctions.java b/ops-image/src/main/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctions.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctions.java rename to ops-image/src/main/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctions.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidges.java b/ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidges.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidges.java rename to ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidges.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionMetadata.java b/ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionMetadata.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionMetadata.java rename to ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionMetadata.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionUtils.java b/ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionUtils.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionUtils.java rename to ops-image/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionUtils.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultGeometricMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultGeometricMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultGeometricMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultGeometricMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultHarmonicMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultHarmonicMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultHarmonicMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultHarmonicMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultKurtosis.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultKurtosis.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultKurtosis.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultKurtosis.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMax.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMax.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMax.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMax.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMedian.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMedian.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMedian.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMedian.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMin.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMin.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMin.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMin.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMinMax.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMinMax.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMinMax.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMinMax.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment1AboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment1AboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment1AboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment1AboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment2AboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment2AboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment2AboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment2AboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment3AboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment3AboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment3AboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment3AboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment4AboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment4AboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment4AboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMoment4AboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMomentNAboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMomentNAboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMomentNAboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultMomentNAboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPNorm.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPNorm.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPNorm.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPNorm.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPercentile.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPercentile.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPercentile.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultPercentile.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQNorm.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQNorm.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQNorm.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQNorm.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQuantile.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQuantile.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQuantile.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultQuantile.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSize.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSize.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSize.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSize.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSkewness.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSkewness.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSkewness.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSkewness.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultStandardDeviation.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultStandardDeviation.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultStandardDeviation.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultStandardDeviation.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSum.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSum.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSum.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSum.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfInverses.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfInverses.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfInverses.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfInverses.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfLogs.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfLogs.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfLogs.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfLogs.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfSquares.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfSquares.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfSquares.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultSumOfSquares.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultVariance.java b/ops-image/src/main/java/org/scijava/ops/image/stats/DefaultVariance.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/DefaultVariance.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/DefaultVariance.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IntegralMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IntegralMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IntegralMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IntegralMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IntegralSum.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IntegralSum.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IntegralSum.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IntegralSum.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IntegralVariance.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IntegralVariance.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IntegralVariance.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IntegralVariance.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableGeometricMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableGeometricMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableGeometricMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableGeometricMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableHarmonicMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableHarmonicMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableHarmonicMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableHarmonicMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMax.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableMax.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMax.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableMax.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMin.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableMin.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMin.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableMin.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMinMax.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableMinMax.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMinMax.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableMinMax.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment3AboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment3AboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment3AboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment3AboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment4AboutMean.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment4AboutMean.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment4AboutMean.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableMoment4AboutMean.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSize.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableSize.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSize.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableSize.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableStandardDeviation.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableStandardDeviation.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableStandardDeviation.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableStandardDeviation.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSum.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableSum.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSum.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableSum.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfInverses.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfInverses.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfInverses.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfInverses.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfLogs.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfLogs.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfLogs.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfLogs.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfSquares.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfSquares.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfSquares.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableSumOfSquares.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableVariance.java b/ops-image/src/main/java/org/scijava/ops/image/stats/IterableVariance.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/IterableVariance.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/IterableVariance.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/stats/regression/leastSquares/Quadric.java b/ops-image/src/main/java/org/scijava/ops/image/stats/regression/leastSquares/Quadric.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/stats/regression/leastSquares/Quadric.java rename to ops-image/src/main/java/org/scijava/ops/image/stats/regression/leastSquares/Quadric.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/Chunk.java b/ops-image/src/main/java/org/scijava/ops/image/thread/chunker/Chunk.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/Chunk.java rename to ops-image/src/main/java/org/scijava/ops/image/thread/chunker/Chunk.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/ChunkerInterleaved.java b/ops-image/src/main/java/org/scijava/ops/image/thread/chunker/ChunkerInterleaved.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/ChunkerInterleaved.java rename to ops-image/src/main/java/org/scijava/ops/image/thread/chunker/ChunkerInterleaved.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/CursorBasedChunk.java b/ops-image/src/main/java/org/scijava/ops/image/thread/chunker/CursorBasedChunk.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/CursorBasedChunk.java rename to ops-image/src/main/java/org/scijava/ops/image/thread/chunker/CursorBasedChunk.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/DefaultChunker.java b/ops-image/src/main/java/org/scijava/ops/image/thread/chunker/DefaultChunker.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/thread/chunker/DefaultChunker.java rename to ops-image/src/main/java/org/scijava/ops/image/thread/chunker/DefaultChunker.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractComputeThresholdHistogram.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractComputeThresholdHistogram.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractComputeThresholdHistogram.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/AbstractComputeThresholdHistogram.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/Thresholds.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyConstantThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyConstantThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyConstantThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyConstantThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparable.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparable.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparable.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparable.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparator.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparator.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparator.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparator.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ij1/ComputeIJ1Threshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/ij1/ComputeIJ1Threshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/ij1/ComputeIJ1Threshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/ij1/ComputeIJ1Threshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/intermodes/ComputeIntermodesThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/intermodes/ComputeIntermodesThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/intermodes/ComputeIntermodesThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/intermodes/ComputeIntermodesThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/isoData/ComputeIsoDataThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/isoData/ComputeIsoDataThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/isoData/ComputeIsoDataThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/isoData/ComputeIsoDataThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/li/ComputeLiThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/li/ComputeLiThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/li/ComputeLiThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/li/ComputeLiThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/LocalContrastThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/LocalContrastThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/LocalContrastThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localContrast/LocalContrastThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThresholdIntegral.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThresholdIntegral.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThresholdIntegral.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThresholdIntegral.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/LocalMeanThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/LocalMeanThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/LocalMeanThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMean/LocalMeanThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/ComputeLocalMedianThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/ComputeLocalMedianThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/ComputeLocalMedianThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/ComputeLocalMedianThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/LocalMedianThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/LocalMedianThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/LocalMedianThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMedian/LocalMedianThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/ComputeLocalMidGreyThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/ComputeLocalMidGreyThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/ComputeLocalMidGreyThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/ComputeLocalMidGreyThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/LocalMidGreyThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/LocalMidGreyThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/LocalMidGreyThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localMidGrey/LocalMidGreyThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/maxEntropy/ComputeMaxEntropyThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/maxEntropy/ComputeMaxEntropyThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/maxEntropy/ComputeMaxEntropyThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/maxEntropy/ComputeMaxEntropyThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/maxLikelihood/ComputeMaxLikelihoodThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/maxLikelihood/ComputeMaxLikelihoodThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/maxLikelihood/ComputeMaxLikelihoodThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/maxLikelihood/ComputeMaxLikelihoodThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/mean/ComputeMeanThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/mean/ComputeMeanThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/mean/ComputeMeanThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/mean/ComputeMeanThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minimum/ComputeMinimumThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/minimum/ComputeMinimumThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/minimum/ComputeMinimumThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/minimum/ComputeMinimumThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/moments/ComputeMomentsThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/moments/ComputeMomentsThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/moments/ComputeMomentsThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/moments/ComputeMomentsThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/percentile/ComputePercentileThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/percentile/ComputePercentileThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/percentile/ComputePercentileThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/percentile/ComputePercentileThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/renyiEntropy/ComputeRenyiEntropyThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/renyiEntropy/ComputeRenyiEntropyThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/renyiEntropy/ComputeRenyiEntropyThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/renyiEntropy/ComputeRenyiEntropyThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rosin/ComputeRosinThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/rosin/ComputeRosinThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/rosin/ComputeRosinThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/rosin/ComputeRosinThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/shanbhag/ComputeShanbhagThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/shanbhag/ComputeShanbhagThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/shanbhag/ComputeShanbhagThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/shanbhag/ComputeShanbhagThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/triangle/ComputeTriangleThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/triangle/ComputeTriangleThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/triangle/ComputeTriangleThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/triangle/ComputeTriangleThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/yen/ComputeYenThreshold.java b/ops-image/src/main/java/org/scijava/ops/image/threshold/yen/ComputeYenThreshold.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/threshold/yen/ComputeYenThreshold.java rename to ops-image/src/main/java/org/scijava/ops/image/threshold/yen/ComputeYenThreshold.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/topology/BoxCount.java b/ops-image/src/main/java/org/scijava/ops/image/topology/BoxCount.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/topology/BoxCount.java rename to ops-image/src/main/java/org/scijava/ops/image/topology/BoxCount.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26N.java b/ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26N.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26N.java rename to ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26N.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloating.java b/ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloating.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloating.java rename to ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloating.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrection.java b/ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrection.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrection.java rename to ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrection.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/Octant.java b/ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/Octant.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/Octant.java rename to ops-image/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/Octant.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/DefaultTransformView.java b/ops-image/src/main/java/org/scijava/ops/image/transform/DefaultTransformView.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/transform/DefaultTransformView.java rename to ops-image/src/main/java/org/scijava/ops/image/transform/DefaultTransformView.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/Transforms.java b/ops-image/src/main/java/org/scijava/ops/image/transform/Transforms.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/transform/Transforms.java rename to ops-image/src/main/java/org/scijava/ops/image/transform/Transforms.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelComputer.java b/ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelComputer.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelComputer.java rename to ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelComputer.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelFunction.java b/ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelFunction.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelFunction.java rename to ops-image/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelFunction.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SliceRAI2RAI.java b/ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SliceRAI2RAI.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SliceRAI2RAI.java rename to ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SliceRAI2RAI.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SlicesII.java b/ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SlicesII.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SlicesII.java rename to ops-image/src/main/java/org/scijava/ops/image/transform/slice/slice/SlicesII.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/ExtendedRAITypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/ExtendedRAITypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/ExtendedRAITypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/ExtendedRAITypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/Histogram1dTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/Histogram1dTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/Histogram1dTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/Histogram1dTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/ImgFactoryTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/ImgFactoryTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/ImgFactoryTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/ImgFactoryTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/ImgLabelingTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/ImgLabelingTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/ImgLabelingTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/ImgLabelingTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/LabelingMappingTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/LabelingMappingTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/LabelingMappingTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/LabelingMappingTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/NativeImgTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/NativeImgTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/NativeImgTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/NativeImgTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsConstantValueFactoryTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsConstantValueFactoryTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsConstantValueFactoryTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsConstantValueFactoryTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsFactoryTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsFactoryTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsFactoryTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsFactoryTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsRandomValueFactoryTypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsRandomValueFactoryTypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsRandomValueFactoryTypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/OutOfBoundsRandomValueFactoryTypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/RAITypeExtractor.java b/ops-image/src/main/java/org/scijava/ops/image/types/RAITypeExtractor.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/RAITypeExtractor.java rename to ops-image/src/main/java/org/scijava/ops/image/types/RAITypeExtractor.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypes.java b/ops-image/src/main/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/types/minValue/MinValueRealTypes.java b/ops-image/src/main/java/org/scijava/ops/image/types/minValue/MinValueRealTypes.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/types/minValue/MinValueRealTypes.java rename to ops-image/src/main/java/org/scijava/ops/image/types/minValue/MinValueRealTypes.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/util/BigComplex.java b/ops-image/src/main/java/org/scijava/ops/image/util/BigComplex.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/util/BigComplex.java rename to ops-image/src/main/java/org/scijava/ops/image/util/BigComplex.java diff --git a/scijava-ops-image/src/main/java/org/scijava/ops/image/util/UnboundedIntegerType.java b/ops-image/src/main/java/org/scijava/ops/image/util/UnboundedIntegerType.java similarity index 100% rename from scijava-ops-image/src/main/java/org/scijava/ops/image/util/UnboundedIntegerType.java rename to ops-image/src/main/java/org/scijava/ops/image/util/UnboundedIntegerType.java diff --git a/scijava-ops-image/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor b/ops-image/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor similarity index 100% rename from scijava-ops-image/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor rename to ops-image/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractColocalisationTest.java b/ops-image/src/test/java/org/scijava/ops/image/AbstractColocalisationTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractColocalisationTest.java rename to ops-image/src/test/java/org/scijava/ops/image/AbstractColocalisationTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java b/ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java rename to ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java index 94b4ff35c..81fe01274 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java @@ -67,7 +67,7 @@ import org.apache.commons.math3.geometry.euclidean.threed.Vector3D; import org.junit.jupiter.api.BeforeEach; -import org.scijava.util.LongArray; +import org.scijava.collections.LongArray; /** * @author Daniel Seebacher (University of Konstanz) diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractOpTest.java b/ops-image/src/test/java/org/scijava/ops/image/AbstractOpTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractOpTest.java rename to ops-image/src/test/java/org/scijava/ops/image/AbstractOpTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java b/ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java rename to ops-image/src/test/java/org/scijava/ops/image/OpRegressionTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/adapt/LiftFunctionsToImgTest.java b/ops-image/src/test/java/org/scijava/ops/image/adapt/LiftFunctionsToImgTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/adapt/LiftFunctionsToImgTest.java rename to ops-image/src/test/java/org/scijava/ops/image/adapt/LiftFunctionsToImgTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/MergeSortTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/MergeSortTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/MergeSortTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/MergeSortTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/icq/LiICQTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/icq/LiICQTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/icq/LiICQTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/icq/LiICQTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRankTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRankTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRankTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRankTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsonsTest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsonsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsonsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsonsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/saca/SACATest.java b/ops-image/src/test/java/org/scijava/ops/image/coloc/saca/SACATest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/coloc/saca/SACATest.java rename to ops-image/src/test/java/org/scijava/ops/image/coloc/saca/SACATest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/convert/ConvertIIsTest.java b/ops-image/src/test/java/org/scijava/ops/image/convert/ConvertIIsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/convert/ConvertIIsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/convert/ConvertIIsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/convert/ConvertMapTest.java b/ops-image/src/test/java/org/scijava/ops/image/convert/ConvertMapTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/convert/ConvertMapTest.java rename to ops-image/src/test/java/org/scijava/ops/image/convert/ConvertMapTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/convert/RAIConvertersTest.java b/ops-image/src/test/java/org/scijava/ops/image/convert/RAIConvertersTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/convert/RAIConvertersTest.java rename to ops-image/src/test/java/org/scijava/ops/image/convert/RAIConvertersTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.java b/ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.java rename to ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertImages.java b/ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertImages.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertImages.java rename to ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertImages.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertNumbersToRealTypes.java b/ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertNumbersToRealTypes.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertNumbersToRealTypes.java rename to ops-image/src/test/java/org/scijava/ops/image/convert/TestConvertNumbersToRealTypes.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyIITest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyIITest.java similarity index 98% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyIITest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyIITest.java index ff319f892..9f50a4b98 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyIITest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyIITest.java @@ -36,7 +36,7 @@ import org.junit.jupiter.api.Test; import org.scijava.function.Computers; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; import org.scijava.ops.image.AbstractOpTest; import net.imglib2.Cursor; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgLabelingTest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgLabelingTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgLabelingTest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgLabelingTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java similarity index 98% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java index 6f4e5f7e3..d899a71da 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java @@ -42,7 +42,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Test {@link CopyImg}. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java index b4a6b2ed1..70a24c7dd 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java @@ -32,7 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; import org.scijava.ops.image.AbstractOpTest; import net.imglib2.Cursor; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyLabelingMappingTest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyLabelingMappingTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyLabelingMappingTest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyLabelingMappingTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java index 90f5bf423..f882244cd 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java @@ -47,7 +47,7 @@ import org.scijava.function.Computers; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyTypeTest.java b/ops-image/src/test/java/org/scijava/ops/image/copy/CopyTypeTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/copy/CopyTypeTest.java rename to ops-image/src/test/java/org/scijava/ops/image/copy/CopyTypeTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateImgTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateImgTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateImgTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateImgTest.java index 9a2cfb7ef..6a13783d5 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateImgTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/create/CreateImgTest.java @@ -61,7 +61,7 @@ import org.scijava.function.Functions; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests several ways to create an image diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernel2ndDerivBiGaussTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateKernel2ndDerivBiGaussTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernel2ndDerivBiGaussTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateKernel2ndDerivBiGaussTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelBiGaussTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelBiGaussTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelBiGaussTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelBiGaussTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelDiffractionTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelDiffractionTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelDiffractionTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelDiffractionTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaborTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaborTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaborTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaborTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaussTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaussTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaussTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelGaussTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelLogTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelLogTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelLogTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateKernelLogTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java index b19fcae59..e5428b548 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java @@ -49,7 +49,7 @@ import org.scijava.function.Functions; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests several ways to create an image diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateNativeTypeTest.java b/ops-image/src/test/java/org/scijava/ops/image/create/CreateNativeTypeTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/create/CreateNativeTypeTest.java rename to ops-image/src/test/java/org/scijava/ops/image/create/CreateNativeTypeTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/DeconvolveTest.java b/ops-image/src/test/java/org/scijava/ops/image/deconvolve/DeconvolveTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/deconvolve/DeconvolveTest.java rename to ops-image/src/test/java/org/scijava/ops/image/deconvolve/DeconvolveTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/describe/ImgLib2DescriptorsTest.java b/ops-image/src/test/java/org/scijava/ops/image/describe/ImgLib2DescriptorsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/describe/ImgLib2DescriptorsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/describe/ImgLib2DescriptorsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/haralick/HaralickFeatureTest.java b/ops-image/src/test/java/org/scijava/ops/image/features/haralick/HaralickFeatureTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/features/haralick/HaralickFeatureTest.java rename to ops-image/src/test/java/org/scijava/ops/image/features/haralick/HaralickFeatureTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2DTest.java b/ops-image/src/test/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2DTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2DTest.java rename to ops-image/src/test/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2DTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/lbp2d/LBP2dFeatureTest.java b/ops-image/src/test/java/org/scijava/ops/image/features/lbp2d/LBP2dFeatureTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/features/lbp2d/LBP2dFeatureTest.java rename to ops-image/src/test/java/org/scijava/ops/image/features/lbp2d/LBP2dFeatureTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/tamura2d/Tamura2dFeatureTest.java b/ops-image/src/test/java/org/scijava/ops/image/features/tamura2d/Tamura2dFeatureTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/features/tamura2d/Tamura2dFeatureTest.java rename to ops-image/src/test/java/org/scijava/ops/image/features/tamura2d/Tamura2dFeatureTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/features/zernike/ZernikeFeatureTest.java b/ops-image/src/test/java/org/scijava/ops/image/features/zernike/ZernikeFeatureTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/features/zernike/ZernikeFeatureTest.java rename to ops-image/src/test/java/org/scijava/ops/image/features/zernike/ZernikeFeatureTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/FFTTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/FFTTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/FFTTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/FFTTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/NonLinearFiltersTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/NonLinearFiltersTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/NonLinearFiltersTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/NonLinearFiltersTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/addNoise/NoiseAddersTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/addNoise/NoiseAddersTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/addNoise/NoiseAddersTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/addNoise/NoiseAddersTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/bilateral/DefaultBilateralTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/bilateral/DefaultBilateralTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/bilateral/DefaultBilateralTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/bilateral/DefaultBilateralTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/convolve/ConvolveTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/convolve/ConvolveTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/convolve/ConvolveTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/convolve/ConvolveTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/correlate/CorrelateTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/correlate/CorrelateTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/correlate/CorrelateTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/correlate/CorrelateTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/derivative/PartialDerivativeFilterTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/derivative/PartialDerivativeFilterTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/derivative/PartialDerivativeFilterTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/derivative/PartialDerivativeFilterTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGaussTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGaussTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGaussTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGaussTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/dog/DoGTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/dog/DoGTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/dog/DoGTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/dog/DoGTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumberTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumberTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumberTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumberTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/gauss/GaussTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/gauss/GaussTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/gauss/GaussTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/gauss/GaussTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/hessian/HessianFilterTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/hessian/HessianFilterTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/hessian/HessianFilterTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/hessian/HessianFilterTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/mean/MeanFilterTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/mean/MeanFilterTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/mean/MeanFilterTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/mean/MeanFilterTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/sobel/SobelFilterTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/sobel/SobelFilterTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/sobel/SobelFilterTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/sobel/SobelFilterTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/filter/vesselness/FrangiVesselnessTest.java b/ops-image/src/test/java/org/scijava/ops/image/filter/vesselness/FrangiVesselnessTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/filter/vesselness/FrangiVesselnessTest.java rename to ops-image/src/test/java/org/scijava/ops/image/filter/vesselness/FrangiVesselnessTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java b/ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java rename to ops-image/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/MeshFeatureTests.java b/ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/MeshFeatureTests.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/MeshFeatureTests.java rename to ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/MeshFeatureTests.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/QuickHull3DTest.java b/ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/QuickHull3DTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/QuickHull3DTest.java rename to ops-image/src/test/java/org/scijava/ops/image/geom/geom3d/QuickHull3DTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/ascii/ASCIITest.java b/ops-image/src/test/java/org/scijava/ops/image/image/ascii/ASCIITest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/ascii/ASCIITest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/ascii/ASCIITest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java index 58a7306ce..36798b1e0 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java @@ -40,7 +40,7 @@ import org.junit.jupiter.api.Test; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * @author Simon Schmid (University of Konstanz) diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java similarity index 98% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java index e347952a3..50734496f 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java @@ -33,7 +33,7 @@ import org.junit.jupiter.api.Test; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; import org.scijava.ops.image.AbstractOpTest; import net.imglib2.FinalInterval; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java index 09357c724..367461e13 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java @@ -33,7 +33,7 @@ import org.junit.jupiter.api.Test; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; import org.scijava.ops.image.AbstractOpTest; import net.imglib2.FinalInterval; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/fill/FillTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/fill/FillTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/fill/FillTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/fill/FillTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/histogram/DefaultHistogramTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/histogram/DefaultHistogramTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/histogram/DefaultHistogramTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/histogram/DefaultHistogramTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralCursorTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralCursorTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralCursorTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralCursorTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/SquareIntegralImgTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/integral/SquareIntegralImgTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/integral/SquareIntegralImgTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/integral/SquareIntegralImgTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/invert/InvertTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/invert/InvertTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/invert/InvertTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/invert/InvertTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/normalize/NormalizeTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/normalize/NormalizeTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/normalize/NormalizeTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/normalize/NormalizeTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinaryTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinaryTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinaryTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinaryTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java index 9e45a02ab..e333ede64 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java @@ -36,7 +36,7 @@ import org.junit.jupiter.api.Test; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; import org.scijava.ops.image.AbstractOpTest; import net.imglib2.Cursor; diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedTest.java b/ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedTest.java rename to ops-image/src/test/java/org/scijava/ops/image/image/watershed/WatershedTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/imagemoments/ImageMomentsTest.java b/ops-image/src/test/java/org/scijava/ops/image/imagemoments/ImageMomentsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/imagemoments/ImageMomentsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/imagemoments/ImageMomentsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/MergeLabelingTest.java b/ops-image/src/test/java/org/scijava/ops/image/labeling/MergeLabelingTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/labeling/MergeLabelingTest.java rename to ops-image/src/test/java/org/scijava/ops/image/labeling/MergeLabelingTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3dTest.java b/ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3dTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3dTest.java rename to ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3dTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3fTest.java b/ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3fTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3fTest.java rename to ops-image/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3fTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/logic/BooleanTypeLogicTest.java b/ops-image/src/test/java/org/scijava/ops/image/logic/BooleanTypeLogicTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/logic/BooleanTypeLogicTest.java rename to ops-image/src/test/java/org/scijava/ops/image/logic/BooleanTypeLogicTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/logic/ConditionalTest.java b/ops-image/src/test/java/org/scijava/ops/image/logic/ConditionalTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/logic/ConditionalTest.java rename to ops-image/src/test/java/org/scijava/ops/image/logic/ConditionalTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodTest.java b/ops-image/src/test/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodTest.java rename to ops-image/src/test/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/math/BinaryNumericTypeMathTest.java b/ops-image/src/test/java/org/scijava/ops/image/math/BinaryNumericTypeMathTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/math/BinaryNumericTypeMathTest.java rename to ops-image/src/test/java/org/scijava/ops/image/math/BinaryNumericTypeMathTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/math/BinaryRealTypeMathTest.java b/ops-image/src/test/java/org/scijava/ops/image/math/BinaryRealTypeMathTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/math/BinaryRealTypeMathTest.java rename to ops-image/src/test/java/org/scijava/ops/image/math/BinaryRealTypeMathTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/math/ImageBinaryMathTest.java b/ops-image/src/test/java/org/scijava/ops/image/math/ImageBinaryMathTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/math/ImageBinaryMathTest.java rename to ops-image/src/test/java/org/scijava/ops/image/math/ImageBinaryMathTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/math/UnaryRealTypeMathTest.java b/ops-image/src/test/java/org/scijava/ops/image/math/UnaryRealTypeMathTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/math/UnaryRealTypeMathTest.java rename to ops-image/src/test/java/org/scijava/ops/image/math/UnaryRealTypeMathTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/math/add/AddNumericTypeBinaryMathAddTest.java b/ops-image/src/test/java/org/scijava/ops/image/math/add/AddNumericTypeBinaryMathAddTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/math/add/AddNumericTypeBinaryMathAddTest.java rename to ops-image/src/test/java/org/scijava/ops/image/math/add/AddNumericTypeBinaryMathAddTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/MorphologyOpsTest.java b/ops-image/src/test/java/org/scijava/ops/image/morphology/MorphologyOpsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/MorphologyOpsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/morphology/MorphologyOpsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/blackTopHat/BlackTopHatTest.java b/ops-image/src/test/java/org/scijava/ops/image/morphology/blackTopHat/BlackTopHatTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/blackTopHat/BlackTopHatTest.java rename to ops-image/src/test/java/org/scijava/ops/image/morphology/blackTopHat/BlackTopHatTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/outline/OutlineTest.java b/ops-image/src/test/java/org/scijava/ops/image/morphology/outline/OutlineTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/outline/OutlineTest.java rename to ops-image/src/test/java/org/scijava/ops/image/morphology/outline/OutlineTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/thin/ThinningTest.java b/ops-image/src/test/java/org/scijava/ops/image/morphology/thin/ThinningTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/morphology/thin/ThinningTest.java rename to ops-image/src/test/java/org/scijava/ops/image/morphology/thin/ThinningTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctionsTest.java b/ops-image/src/test/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctionsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctionsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctionsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidgesTest.java b/ops-image/src/test/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidgesTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidgesTest.java rename to ops-image/src/test/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidgesTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/DefaultMedianTest.java b/ops-image/src/test/java/org/scijava/ops/image/stats/DefaultMedianTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/stats/DefaultMedianTest.java rename to ops-image/src/test/java/org/scijava/ops/image/stats/DefaultMedianTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/DefaultPNormTest.java b/ops-image/src/test/java/org/scijava/ops/image/stats/DefaultPNormTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/stats/DefaultPNormTest.java rename to ops-image/src/test/java/org/scijava/ops/image/stats/DefaultPNormTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/DefaultQNormTest.java b/ops-image/src/test/java/org/scijava/ops/image/stats/DefaultQNormTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/stats/DefaultQNormTest.java rename to ops-image/src/test/java/org/scijava/ops/image/stats/DefaultQNormTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/StatisticsTest.java b/ops-image/src/test/java/org/scijava/ops/image/stats/StatisticsTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/stats/StatisticsTest.java rename to ops-image/src/test/java/org/scijava/ops/image/stats/StatisticsTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/stats/regression/leastSquares/QuadricTest.java b/ops-image/src/test/java/org/scijava/ops/image/stats/regression/leastSquares/QuadricTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/stats/regression/leastSquares/QuadricTest.java rename to ops-image/src/test/java/org/scijava/ops/image/stats/regression/leastSquares/QuadricTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java b/ops-image/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java similarity index 95% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java rename to ops-image/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java index f74150eb8..426f18ff8 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java @@ -48,7 +48,6 @@ import org.junit.jupiter.api.BeforeEach; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.ListUtils; /** * Tests for threshold ops. @@ -95,13 +94,17 @@ protected Histogram1d histogram() { } protected void assertThreshold(final int expected, final Object actual) { - final Object value = actual instanceof List ? ListUtils.first( - (List) actual) : actual; + final Object value = actual instanceof List ? first((List) actual) + : actual; assertTrue(value instanceof UnsignedShortType); final UnsignedShortType threshold = (UnsignedShortType) value; assertEquals(expected, threshold.get()); } + private static T first(final List list) { + return list == null || list.isEmpty() ? null : list.get(0); + } + protected Img bitmap() throws IncompatibleTypeException { return in.factory().imgFactory(new BitType()).create(in, new BitType()); } diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java b/ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java rename to ops-image/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/ApplyConstantThresholdTest.java b/ops-image/src/test/java/org/scijava/ops/image/threshold/apply/ApplyConstantThresholdTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/ApplyConstantThresholdTest.java rename to ops-image/src/test/java/org/scijava/ops/image/threshold/apply/ApplyConstantThresholdTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java b/ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java rename to ops-image/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/topology/BoxCountTest.java b/ops-image/src/test/java/org/scijava/ops/image/topology/BoxCountTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/topology/BoxCountTest.java rename to ops-image/src/test/java/org/scijava/ops/image/topology/BoxCountTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloatingTest.java b/ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloatingTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloatingTest.java rename to ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloatingTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NTest.java b/ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NTest.java rename to ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrectionTest.java b/ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrectionTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrectionTest.java rename to ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrectionTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/OctantTest.java b/ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/OctantTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/OctantTest.java rename to ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/OctantTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/TestHelper.java b/ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/TestHelper.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/TestHelper.java rename to ops-image/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/TestHelper.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/addDimensionView/AddDimensionViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/addDimensionView/AddDimensionViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/addDimensionView/AddDimensionViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/addDimensionView/AddDimensionViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/collapseNumericView/CollapseNumericViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/collapseNumericView/CollapseNumericViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/collapseNumericView/CollapseNumericViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/collapseNumericView/CollapseNumericViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/collapseRealView/CollapseRealViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/collapseRealView/CollapseRealViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/collapseRealView/CollapseRealViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/collapseRealView/CollapseRealViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/collapseView/CollapseViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/collapseView/CollapseViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/collapseView/CollapseViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/collapseView/CollapseViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java index cadf58b12..b46f668cc 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java @@ -52,7 +52,7 @@ import org.scijava.function.Functions; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests {@link org.scijava.ops.image.Ops.Transform.ConcatenateView} ops. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/dropSingleDimensionsView/DropSingletonDimensionsViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/dropSingleDimensionsView/DropSingletonDimensionsViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/dropSingleDimensionsView/DropSingletonDimensionsViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/dropSingleDimensionsView/DropSingletonDimensionsViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendBorderView/ExtendBorderViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendBorderView/ExtendBorderViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendBorderView/ExtendBorderViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendBorderView/ExtendBorderViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorDoubleView/ExtendMirrorDoubleViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorDoubleView/ExtendMirrorDoubleViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorDoubleView/ExtendMirrorDoubleViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorDoubleView/ExtendMirrorDoubleViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorSingleView/ExtendMirrorSingleViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorSingleView/ExtendMirrorSingleViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorSingleView/ExtendMirrorSingleViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendMirrorSingleView/ExtendMirrorSingleViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendPeriodicView/ExtendPeriodicViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendPeriodicView/ExtendPeriodicViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendPeriodicView/ExtendPeriodicViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendPeriodicView/ExtendPeriodicViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendRandomView/ExtendRandomViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendRandomView/ExtendRandomViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendRandomView/ExtendRandomViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendRandomView/ExtendRandomViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendValueView/ExtendValueViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendValueView/ExtendValueViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendValueView/ExtendValueViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendValueView/ExtendValueViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendView/ExtendViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendView/ExtendViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendView/ExtendViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendView/ExtendViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendZeroView/ExtendZeroViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/extendZeroView/ExtendZeroViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/extendZeroView/ExtendZeroViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/extendZeroView/ExtendZeroViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/flatIterableView/FlatIterableViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/flatIterableView/FlatIterableViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/flatIterableView/FlatIterableViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/flatIterableView/FlatIterableViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/hyperSliceView/HyperSliceViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/hyperSliceView/HyperSliceViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/hyperSliceView/HyperSliceViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/hyperSliceView/HyperSliceViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java similarity index 98% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java index 1def32343..b1c3a9d27 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java @@ -46,7 +46,7 @@ import org.junit.jupiter.api.Test; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests {@link org.scijava.ops.image.Ops.Transform.InterpolateView} ops. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java similarity index 98% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java index 1f4954114..763ddceb1 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java @@ -48,7 +48,7 @@ import org.scijava.function.Functions; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests {@link org.scijava.ops.image.Ops.Transform.IntervalView} ops. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/invertAxisView/InvertAxisViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/invertAxisView/InvertAxisViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/invertAxisView/InvertAxisViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/invertAxisView/InvertAxisViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/offsetView/OffsetViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/offsetView/OffsetViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/offsetView/OffsetViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/offsetView/OffsetViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java index 66a9e0c3f..202950a13 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java @@ -52,7 +52,7 @@ import org.scijava.function.Functions; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests {@link org.scijava.ops.image.Ops.Transform.PermuteView} ops. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/project/ProjectTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/project/ProjectTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/project/ProjectTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/project/ProjectTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java similarity index 98% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java index 05eecd6a5..8b0e76eef 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java @@ -47,7 +47,7 @@ import org.junit.jupiter.api.Test; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests {@link org.scijava.ops.image.Ops.Transform.RasterView} ops. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/realTransform/RealTransformTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/realTransform/RealTransformTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/realTransform/RealTransformTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/realTransform/RealTransformTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/rotateView/RotateViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/rotateView/RotateViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/rotateView/RotateViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/rotateView/RotateViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/shearView/ShearViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/shearView/ShearViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/shearView/ShearViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/shearView/ShearViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/slice/slice/SliceTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/slice/slice/SliceTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/slice/slice/SliceTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/slice/slice/SliceTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/stackView/StackViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/stackView/StackViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/stackView/StackViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/stackView/StackViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java similarity index 99% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java index b8fbe3fbe..187d2cc3d 100644 --- a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java +++ b/ops-image/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java @@ -51,7 +51,7 @@ import org.junit.jupiter.api.Test; import org.scijava.ops.api.OpBuilder; import org.scijava.types.Nil; -import org.scijava.util.MersenneTwisterFast; +import org.scijava.common3.MersenneTwisterFast; /** * Tests {@link org.scijava.ops.image.Ops.Transform.SubsampleView} ops. diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/translateView/TranslateViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/translateView/TranslateViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/translateView/TranslateViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/translateView/TranslateViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/unshearView/UnshearViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/unshearView/UnshearViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/unshearView/UnshearViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/unshearView/UnshearViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/transform/zeroMinView/ZeroMinViewTest.java b/ops-image/src/test/java/org/scijava/ops/image/transform/zeroMinView/ZeroMinViewTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/transform/zeroMinView/ZeroMinViewTest.java rename to ops-image/src/test/java/org/scijava/ops/image/transform/zeroMinView/ZeroMinViewTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/types/TypeExtractorTests.java b/ops-image/src/test/java/org/scijava/ops/image/types/TypeExtractorTests.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/types/TypeExtractorTests.java rename to ops-image/src/test/java/org/scijava/ops/image/types/TypeExtractorTests.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftComputersToRAITest.java b/ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftComputersToRAITest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftComputersToRAITest.java rename to ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftComputersToRAITest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftFunctionsToRAITest.java b/ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftFunctionsToRAITest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftFunctionsToRAITest.java rename to ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftFunctionsToRAITest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftNeighborhoodComputersToRAITest.java b/ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftNeighborhoodComputersToRAITest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftNeighborhoodComputersToRAITest.java rename to ops-image/src/test/java/org/scijava/ops/image/types/adapt/LiftNeighborhoodComputersToRAITest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypesTest.java b/ops-image/src/test/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypesTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypesTest.java rename to ops-image/src/test/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypesTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/types/minValue/MinValueRealTypesTest.java b/ops-image/src/test/java/org/scijava/ops/image/types/minValue/MinValueRealTypesTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/types/minValue/MinValueRealTypesTest.java rename to ops-image/src/test/java/org/scijava/ops/image/types/minValue/MinValueRealTypesTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/util/BigComplexTest.java b/ops-image/src/test/java/org/scijava/ops/image/util/BigComplexTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/util/BigComplexTest.java rename to ops-image/src/test/java/org/scijava/ops/image/util/BigComplexTest.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/util/TestImgGeneration.java b/ops-image/src/test/java/org/scijava/ops/image/util/TestImgGeneration.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/util/TestImgGeneration.java rename to ops-image/src/test/java/org/scijava/ops/image/util/TestImgGeneration.java diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/util/TestImgGenerationTest.java b/ops-image/src/test/java/org/scijava/ops/image/util/TestImgGenerationTest.java similarity index 100% rename from scijava-ops-image/src/test/java/org/scijava/ops/image/util/TestImgGenerationTest.java rename to ops-image/src/test/java/org/scijava/ops/image/util/TestImgGenerationTest.java diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-green.tif b/ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-green.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-green.tif rename to ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-green.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-red.tif b/ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-red.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-red.tif rename to ops-image/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-red.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/greenZstack.tif b/ops-image/src/test/resources/org/scijava/ops/image/coloc/greenZstack.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/greenZstack.tif rename to ops-image/src/test/resources/org/scijava/ops/image/coloc/greenZstack.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/redZstack.tif b/ops-image/src/test/resources/org/scijava/ops/image/coloc/redZstack.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/coloc/redZstack.tif rename to ops-image/src/test/resources/org/scijava/ops/image/coloc/redZstack.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_polygon.txt b/ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_polygon.txt similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_polygon.txt rename to ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_polygon.txt diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_testlabel.tif b/ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_testlabel.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_testlabel.tif rename to ops-image/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_testlabel.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_mesh.txt b/ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_mesh.txt similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_mesh.txt rename to ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_mesh.txt diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_testlabel.tif b/ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_testlabel.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_testlabel.tif rename to ops-image/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_testlabel.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/haralick_test_img.tif b/ops-image/src/test/resources/org/scijava/ops/image/features/haralick_test_img.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/haralick_test_img.tif rename to ops-image/src/test/resources/org/scijava/ops/image/features/haralick_test_img.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DInput.png b/ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DInput.png similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DInput.png rename to ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DInput.png diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DResult.tif b/ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DResult.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DResult.tif rename to ops-image/src/test/resources/org/scijava/ops/image/features/hog/HoG2DResult.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/TubesInput.png b/ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/TubesInput.png similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/TubesInput.png rename to ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/TubesInput.png diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/tube.tif b/ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/tube.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/tube.tif rename to ops-image/src/test/resources/org/scijava/ops/image/filter/tubeness/tube.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/filter/vesselness/Result.tif b/ops-image/src/test/resources/org/scijava/ops/image/filter/vesselness/Result.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/filter/vesselness/Result.tif rename to ops-image/src/test/resources/org/scijava/ops/image/filter/vesselness/Result.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/image/watershed/watershed_test_image.png b/ops-image/src/test/resources/org/scijava/ops/image/image/watershed/watershed_test_image.png similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/image/watershed/watershed_test_image.png rename to ops-image/src/test/resources/org/scijava/ops/image/image/watershed/watershed_test_image.png diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/img_with_holes.png b/ops-image/src/test/resources/org/scijava/ops/image/morphology/img_with_holes.png similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/img_with_holes.png rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/img_with_holes.png diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/img_without_holes.png b/ops-image/src/test/resources/org/scijava/ops/image/morphology/img_without_holes.png similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/img_without_holes.png rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/img_without_holes.png diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/inverted_img_with_filled_holes.png b/ops-image/src/test/resources/org/scijava/ops/image/morphology/inverted_img_with_filled_holes.png similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/inverted_img_with_filled_holes.png rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/inverted_img_with_filled_holes.png diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_guoHall.tif b/ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_guoHall.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_guoHall.tif rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_guoHall.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_hilditch.tif b/ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_hilditch.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_hilditch.tif rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_hilditch.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_morphological.tif b/ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_morphological.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_morphological.tif rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_morphological.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_zhangSuen.tif b/ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_zhangSuen.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_zhangSuen.tif rename to ops-image/src/test/resources/org/scijava/ops/image/morphology/thin/result_zhangSuen.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/lowresbridge.tif b/ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/lowresbridge.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/lowresbridge.tif rename to ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/lowresbridge.tif diff --git a/scijava-ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/rotatedscaledcenter.tif b/ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/rotatedscaledcenter.tif similarity index 100% rename from scijava-ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/rotatedscaledcenter.tif rename to ops-image/src/test/resources/org/scijava/ops/image/transform/realTransform/rotatedscaledcenter.tif diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/Globals.list b/ops-image/templates/main/java/org/scijava/ops/image/adapt/Globals.list similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/Globals.list rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/Globals.list diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.list b/ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.list similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.list rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.list diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.vm b/ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.vm similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.vm rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.vm diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.list b/ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.list similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.list rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.list diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.vm b/ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.vm similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.vm rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.vm diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.list b/ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.list similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.list rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.list diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.vm b/ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.vm similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.vm rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.vm diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.list b/ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.list similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.list rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.list diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.vm b/ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.vm similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.vm rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.vm diff --git a/scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/Globals.list b/ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/Globals.list similarity index 100% rename from scijava-ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/Globals.list rename to ops-image/templates/main/java/org/scijava/ops/image/adapt/complexLift/Globals.list diff --git a/scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.list b/ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.list similarity index 100% rename from scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.list rename to ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.list diff --git a/scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.vm b/ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.vm similarity index 100% rename from scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.vm rename to ops-image/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.vm diff --git a/scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.list b/ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.list similarity index 100% rename from scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.list rename to ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.list diff --git a/scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.vm b/ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.vm similarity index 100% rename from scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.vm rename to ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.vm diff --git a/scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.list b/ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.list similarity index 100% rename from scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.list rename to ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.list diff --git a/scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.vm b/ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.vm similarity index 100% rename from scijava-ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.vm rename to ops-image/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.vm diff --git a/scijava-ops-indexer/.gitignore b/ops-indexer/.gitignore similarity index 100% rename from scijava-ops-indexer/.gitignore rename to ops-indexer/.gitignore diff --git a/scijava-ops-indexer/LICENSE.txt b/ops-indexer/LICENSE.txt similarity index 100% rename from scijava-ops-indexer/LICENSE.txt rename to ops-indexer/LICENSE.txt diff --git a/scijava-ops-indexer/NOTICE.txt b/ops-indexer/NOTICE.txt similarity index 100% rename from scijava-ops-indexer/NOTICE.txt rename to ops-indexer/NOTICE.txt diff --git a/scijava-ops-indexer/README.md b/ops-indexer/README.md similarity index 92% rename from scijava-ops-indexer/README.md rename to ops-indexer/README.md index c5b20fd00..03cd91661 100644 --- a/scijava-ops-indexer/README.md +++ b/ops-indexer/README.md @@ -12,7 +12,7 @@ To enable Op YAML generation in your build process, add the following to your `p ``` -**NOTE**: Until this repository is moved out of the SciJava Incubator and the following is added to pom-scijava, the following must also be added to your POM: +**NOTE**: Until the following is added to pom-scijava, the following must also be added to your POM: ```xml @@ -24,7 +24,7 @@ To enable Op YAML generation in your build process, add the following to your `p org.scijava scijava-ops-indexer - ${project.version} + ${scijava-ops-indexer.version} true diff --git a/scijava-ops-indexer/pom.xml b/ops-indexer/pom.xml similarity index 100% rename from scijava-ops-indexer/pom.xml rename to ops-indexer/pom.xml diff --git a/scijava-ops-indexer/src/main/java/module-info.java b/ops-indexer/src/main/java/module-info.java similarity index 100% rename from scijava-ops-indexer/src/main/java/module-info.java rename to ops-indexer/src/main/java/module-info.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/InvalidOpImplException.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/InvalidOpImplException.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/InvalidOpImplException.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/InvalidOpImplException.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpClassImplData.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/OpClassImplData.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpClassImplData.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/OpClassImplData.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpFieldImplData.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/OpFieldImplData.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpFieldImplData.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/OpFieldImplData.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplData.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplData.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplData.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplData.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplNoteParser.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplNoteParser.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplNoteParser.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/OpImplNoteParser.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpMethodImplData.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/OpMethodImplData.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpMethodImplData.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/OpMethodImplData.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpParameter.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/OpParameter.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/OpParameter.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/OpParameter.java diff --git a/scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/ProcessingUtils.java b/ops-indexer/src/main/java/org/scijava/ops/indexer/ProcessingUtils.java similarity index 100% rename from scijava-ops-indexer/src/main/java/org/scijava/ops/indexer/ProcessingUtils.java rename to ops-indexer/src/main/java/org/scijava/ops/indexer/ProcessingUtils.java diff --git a/scijava-ops-indexer/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/ops-indexer/src/main/resources/META-INF/services/javax.annotation.processing.Processor similarity index 100% rename from scijava-ops-indexer/src/main/resources/META-INF/services/javax.annotation.processing.Processor rename to ops-indexer/src/main/resources/META-INF/services/javax.annotation.processing.Processor diff --git a/scijava-ops-opencv/LICENSE.txt b/ops-opencv/LICENSE.txt similarity index 100% rename from scijava-ops-opencv/LICENSE.txt rename to ops-opencv/LICENSE.txt diff --git a/scijava-ops-opencv/README.md b/ops-opencv/README.md similarity index 100% rename from scijava-ops-opencv/README.md rename to ops-opencv/README.md diff --git a/scijava-ops-opencv/opencv-ops.yaml b/ops-opencv/opencv-ops.yaml similarity index 100% rename from scijava-ops-opencv/opencv-ops.yaml rename to ops-opencv/opencv-ops.yaml diff --git a/scijava-ops-opencv/pom.xml b/ops-opencv/pom.xml similarity index 93% rename from scijava-ops-opencv/pom.xml rename to ops-opencv/pom.xml index eae13d5b8..527270b0c 100644 --- a/scijava-ops-opencv/pom.xml +++ b/ops-opencv/pom.xml @@ -100,11 +100,20 @@ --add-reads org.scijava.ops.opencv=org.scijava.ops.api,net.imglib2 + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 2.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT diff --git a/scijava-ops-opencv/src/main/java/module-info.java b/ops-opencv/src/main/java/module-info.java similarity index 100% rename from scijava-ops-opencv/src/main/java/module-info.java rename to ops-opencv/src/main/java/module-info.java diff --git a/scijava-ops-opencv/src/main/java/org/scijava/ops/opencv/MatCopier.java b/ops-opencv/src/main/java/org/scijava/ops/opencv/MatCopier.java similarity index 100% rename from scijava-ops-opencv/src/main/java/org/scijava/ops/opencv/MatCopier.java rename to ops-opencv/src/main/java/org/scijava/ops/opencv/MatCopier.java diff --git a/scijava-ops-opencv/src/main/java/org/scijava/ops/opencv/OpenCVDescriptors.java b/ops-opencv/src/main/java/org/scijava/ops/opencv/OpenCVDescriptors.java similarity index 100% rename from scijava-ops-opencv/src/main/java/org/scijava/ops/opencv/OpenCVDescriptors.java rename to ops-opencv/src/main/java/org/scijava/ops/opencv/OpenCVDescriptors.java diff --git a/scijava-ops-opencv/src/main/resources/ops.yaml b/ops-opencv/src/main/resources/ops.yaml similarity index 100% rename from scijava-ops-opencv/src/main/resources/ops.yaml rename to ops-opencv/src/main/resources/ops.yaml diff --git a/scijava-ops-opencv/src/test/java/org/scijava/ops/opencv/TestOpenCV.java b/ops-opencv/src/test/java/org/scijava/ops/opencv/TestOpenCV.java similarity index 100% rename from scijava-ops-opencv/src/test/java/org/scijava/ops/opencv/TestOpenCV.java rename to ops-opencv/src/test/java/org/scijava/ops/opencv/TestOpenCV.java diff --git a/scijava-ops-opencv/src/test/resources/HappyFish.jpg b/ops-opencv/src/test/resources/HappyFish.jpg similarity index 100% rename from scijava-ops-opencv/src/test/resources/HappyFish.jpg rename to ops-opencv/src/test/resources/HappyFish.jpg diff --git a/scijava-ops-spi/.gitignore b/ops-spi/.gitignore similarity index 100% rename from scijava-ops-spi/.gitignore rename to ops-spi/.gitignore diff --git a/scijava-ops-spi/LICENSE.txt b/ops-spi/LICENSE.txt similarity index 100% rename from scijava-ops-spi/LICENSE.txt rename to ops-spi/LICENSE.txt diff --git a/scijava-ops-spi/README.md b/ops-spi/README.md similarity index 100% rename from scijava-ops-spi/README.md rename to ops-spi/README.md diff --git a/scijava-ops-spi/pom.xml b/ops-spi/pom.xml similarity index 100% rename from scijava-ops-spi/pom.xml rename to ops-spi/pom.xml diff --git a/scijava-ops-spi/src/main/java/module-info.java b/ops-spi/src/main/java/module-info.java similarity index 100% rename from scijava-ops-spi/src/main/java/module-info.java rename to ops-spi/src/main/java/module-info.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/Nullable.java b/ops-spi/src/main/java/org/scijava/ops/spi/Nullable.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/Nullable.java rename to ops-spi/src/main/java/org/scijava/ops/spi/Nullable.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/Op.java b/ops-spi/src/main/java/org/scijava/ops/spi/Op.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/Op.java rename to ops-spi/src/main/java/org/scijava/ops/spi/Op.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpClass.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpClass.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpClass.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpClass.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpCollection.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpCollection.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpCollection.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpCollection.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpDependency.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpDependency.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpDependency.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpDependency.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpExecutionException.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpExecutionException.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpExecutionException.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpExecutionException.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpField.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpField.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpField.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpField.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpHints.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpHints.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpHints.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpHints.java diff --git a/scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpMethod.java b/ops-spi/src/main/java/org/scijava/ops/spi/OpMethod.java similarity index 100% rename from scijava-ops-spi/src/main/java/org/scijava/ops/spi/OpMethod.java rename to ops-spi/src/main/java/org/scijava/ops/spi/OpMethod.java diff --git a/scijava-ops-tutorial/LICENSE.txt b/ops-tutorial/LICENSE.txt similarity index 100% rename from scijava-ops-tutorial/LICENSE.txt rename to ops-tutorial/LICENSE.txt diff --git a/scijava-ops-tutorial/README.md b/ops-tutorial/README.md similarity index 100% rename from scijava-ops-tutorial/README.md rename to ops-tutorial/README.md diff --git a/scijava-ops-tutorial/environment.yml b/ops-tutorial/environment.yml similarity index 100% rename from scijava-ops-tutorial/environment.yml rename to ops-tutorial/environment.yml diff --git a/scijava-ops-tutorial/notebooks/SciJavaOps.ipynb b/ops-tutorial/notebooks/SciJavaOps.ipynb similarity index 100% rename from scijava-ops-tutorial/notebooks/SciJavaOps.ipynb rename to ops-tutorial/notebooks/SciJavaOps.ipynb diff --git a/scijava-ops-tutorial/notebooks/gateways.py b/ops-tutorial/notebooks/gateways.py similarity index 100% rename from scijava-ops-tutorial/notebooks/gateways.py rename to ops-tutorial/notebooks/gateways.py diff --git a/scijava-ops-tutorial/pom.xml b/ops-tutorial/pom.xml similarity index 95% rename from scijava-ops-tutorial/pom.xml rename to ops-tutorial/pom.xml index 3a7a1a667..9c1654068 100644 --- a/scijava-ops-tutorial/pom.xml +++ b/ops-tutorial/pom.xml @@ -100,14 +100,21 @@ true + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT - 1.0.1-SNAPSHOT 2.0.0-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT diff --git a/scijava-ops-tutorial/resources/ops-dot-math.png b/ops-tutorial/resources/ops-dot-math.png similarity index 100% rename from scijava-ops-tutorial/resources/ops-dot-math.png rename to ops-tutorial/resources/ops-dot-math.png diff --git a/scijava-ops-tutorial/resources/ops-dot.png b/ops-tutorial/resources/ops-dot.png similarity index 100% rename from scijava-ops-tutorial/resources/ops-dot.png rename to ops-tutorial/resources/ops-dot.png diff --git a/scijava-ops-tutorial/scripts/gateways.py b/ops-tutorial/scripts/gateways.py similarity index 100% rename from scijava-ops-tutorial/scripts/gateways.py rename to ops-tutorial/scripts/gateways.py diff --git a/scijava-ops-tutorial/scripts/ops-setup.py b/ops-tutorial/scripts/ops-setup.py similarity index 85% rename from scijava-ops-tutorial/scripts/ops-setup.py rename to ops-tutorial/scripts/ops-setup.py index a2b5e745a..0fb583438 100644 --- a/scijava-ops-tutorial/scripts/ops-setup.py +++ b/ops-tutorial/scripts/ops-setup.py @@ -7,7 +7,7 @@ ops.filter.addPoissonNoise(img) Variables: - ops - fully intialized OpGateway with all SciJava and ImageJ Ops available + ops - fully initialized OpGateway with all SciJava and ImageJ Ops available env - OpEnvironment for more traditional Java-style API ''' diff --git a/scijava-ops-tutorial/scripts/services-test.py b/ops-tutorial/scripts/services-test.py similarity index 100% rename from scijava-ops-tutorial/scripts/services-test.py rename to ops-tutorial/scripts/services-test.py diff --git a/scijava-ops-tutorial/src/main/java/module-info.java b/ops-tutorial/src/main/java/module-info.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/module-info.java rename to ops-tutorial/src/main/java/module-info.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpAdaptation.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpAdaptation.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpAdaptation.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpAdaptation.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpBuilder.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpBuilder.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpBuilder.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpBuilder.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpConversion.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpConversion.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpConversion.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpConversion.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpDependencies.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpDependencies.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpDependencies.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpDependencies.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpParallelization.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpParallelization.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpParallelization.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpParallelization.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpPriorities.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpPriorities.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpPriorities.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpPriorities.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpReduction.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpReduction.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpReduction.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpReduction.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpTypes.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpTypes.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpTypes.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpTypes.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpsIntro.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpsIntro.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpsIntro.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/OpsIntro.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/ParallelComputation.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/ParallelComputation.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/ParallelComputation.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/ParallelComputation.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/ReportingProgress.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/ReportingProgress.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/ReportingProgress.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/ReportingProgress.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/UsingNils.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/UsingNils.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/UsingNils.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/UsingNils.java diff --git a/scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/WritingConciseOps.java b/ops-tutorial/src/main/java/org/scijava/ops/tutorial/WritingConciseOps.java similarity index 100% rename from scijava-ops-tutorial/src/main/java/org/scijava/ops/tutorial/WritingConciseOps.java rename to ops-tutorial/src/main/java/org/scijava/ops/tutorial/WritingConciseOps.java diff --git a/pom.xml b/pom.xml index c86ef69cc..4f7ce2607 100644 --- a/pom.xml +++ b/pom.xml @@ -53,31 +53,51 @@ - scijava-taglets is a javadoc taglet processor used at javadoc generation time to handle Ops's custom tags (@container, @mutable, etc.). --> - scijava-ops-indexer - scijava-taglets + ops-indexer + taglets - scijava-collections - scijava-common3 - scijava-concurrent - scijava-discovery - scijava-discovery-test - scijava-function - scijava-legacy - scijava-meta - scijava-ops-api - scijava-ops-engine - scijava-ops-flim - scijava-ops-image - scijava-ops-opencv - scijava-ops-ext-parser - scijava-ops-spi - scijava-ops-tutorial - scijava-priority - scijava-progress - scijava-struct - scijava-testutil - scijava-types + awt + cli + collections + command3 + common3 + compat + concurrent + context-test + context + convert3 + discovery-test + discovery + events + execute + function + harvest + imagej-compat + index + io3 + javafx + meta + ops-api + ops-compat + ops-engine + ops-ext-parser + ops-flim + ops-image + ops-opencv + ops-spi + ops-tutorial + priority + progress + script3 + settings + spi + struct + swing + testutil + types + ui3-test + ui3 @@ -107,7 +127,7 @@ benchmarks - scijava-ops-benchmarks + ops-benchmarks diff --git a/scijava-priority/.gitignore b/priority/.gitignore similarity index 100% rename from scijava-priority/.gitignore rename to priority/.gitignore diff --git a/scijava-priority/LICENSE.txt b/priority/LICENSE.txt similarity index 100% rename from scijava-priority/LICENSE.txt rename to priority/LICENSE.txt diff --git a/scijava-priority/README.md b/priority/README.md similarity index 100% rename from scijava-priority/README.md rename to priority/README.md diff --git a/scijava-priority/pom.xml b/priority/pom.xml similarity index 100% rename from scijava-priority/pom.xml rename to priority/pom.xml diff --git a/scijava-priority/src/main/java/module-info.java b/priority/src/main/java/module-info.java similarity index 100% rename from scijava-priority/src/main/java/module-info.java rename to priority/src/main/java/module-info.java diff --git a/scijava-priority/src/main/java/org/scijava/priority/Prioritized.java b/priority/src/main/java/org/scijava/priority/Prioritized.java similarity index 100% rename from scijava-priority/src/main/java/org/scijava/priority/Prioritized.java rename to priority/src/main/java/org/scijava/priority/Prioritized.java diff --git a/scijava-priority/src/main/java/org/scijava/priority/Priority.java b/priority/src/main/java/org/scijava/priority/Priority.java similarity index 100% rename from scijava-priority/src/main/java/org/scijava/priority/Priority.java rename to priority/src/main/java/org/scijava/priority/Priority.java diff --git a/scijava-priority/src/test/java/org/scijava/priority/PrioritizedTest.java b/priority/src/test/java/org/scijava/priority/PrioritizedTest.java similarity index 100% rename from scijava-priority/src/test/java/org/scijava/priority/PrioritizedTest.java rename to priority/src/test/java/org/scijava/priority/PrioritizedTest.java diff --git a/scijava-progress/.gitignore b/progress/.gitignore similarity index 100% rename from scijava-progress/.gitignore rename to progress/.gitignore diff --git a/scijava-progress/LICENSE.txt b/progress/LICENSE.txt similarity index 100% rename from scijava-progress/LICENSE.txt rename to progress/LICENSE.txt diff --git a/scijava-progress/README.md b/progress/README.md similarity index 100% rename from scijava-progress/README.md rename to progress/README.md diff --git a/scijava-progress/pom.xml b/progress/pom.xml similarity index 100% rename from scijava-progress/pom.xml rename to progress/pom.xml diff --git a/scijava-progress/src/main/java/module-info.java b/progress/src/main/java/module-info.java similarity index 100% rename from scijava-progress/src/main/java/module-info.java rename to progress/src/main/java/module-info.java diff --git a/scijava-progress/src/main/java/org/scijava/progress/Progress.java b/progress/src/main/java/org/scijava/progress/Progress.java similarity index 88% rename from scijava-progress/src/main/java/org/scijava/progress/Progress.java rename to progress/src/main/java/org/scijava/progress/Progress.java index ba16b3afb..b585a1719 100644 --- a/scijava-progress/src/main/java/org/scijava/progress/Progress.java +++ b/progress/src/main/java/org/scijava/progress/Progress.java @@ -30,6 +30,7 @@ package org.scijava.progress; import java.util.*; +import java.util.concurrent.CancellationException; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; @@ -258,13 +259,47 @@ public static void update(long elements) { * * @param numElements the number of elements completed in the current stage. * @param task the {@link Task} to update + * @throws CancellationException if this thread has been interrupted * @see Task#update(long) + * @see #checkCancellation() */ public static void update(final long numElements, final Task task) { + checkCancellation(); task.update(numElements); pingListeners(task); } + /** + * Throws {@link CancellationException} if this thread has been interrupted. + *

+ * Cancellation in SciJava is thread interruption: a caller holding a + * {@link java.util.concurrent.Future} cancels with + * {@code future.cancel(true)}, which interrupts the worker thread. Work that + * cannot throw {@link InterruptedException} - an {@code Op} implementing a + * plain {@link java.util.function.Function}, for instance - cooperates by + * calling this method periodically. + *

+ *

+ * {@link #update()} calls it already, so anything that reports progress is + * cancellable without further effort. Call it directly in a long loop that + * reports no progress. + *

+ *

+ * The interrupt flag is restored before throwing, so that code further up + * the stack still sees that this thread was cancelled. + *

+ * + * @throws CancellationException if this thread has been interrupted + */ + public static void checkCancellation() { + // NB: Thread.interrupted() clears the flag, hence the restore below. + if (Thread.interrupted()) { + Thread.currentThread().interrupt(); + throw new CancellationException("Cancelled: " + Thread.currentThread() + .getName()); + } + } + /** * Sets the status of the current {@link Task}, pinging any interested * {@link Consumer}s. diff --git a/scijava-progress/src/main/java/org/scijava/progress/Task.java b/progress/src/main/java/org/scijava/progress/Task.java similarity index 100% rename from scijava-progress/src/main/java/org/scijava/progress/Task.java rename to progress/src/main/java/org/scijava/progress/Task.java diff --git a/progress/src/test/java/org/scijava/progress/CancellationTest.java b/progress/src/test/java/org/scijava/progress/CancellationTest.java new file mode 100644 index 000000000..873a6d436 --- /dev/null +++ b/progress/src/test/java/org/scijava/progress/CancellationTest.java @@ -0,0 +1,167 @@ +/*- + * #%L + * An interrupt-based subsystem for progress reporting. + * %% + * Copyright (C) 2021 - 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.progress; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +/** + * Tests that cancellation works the way the platform does it: a caller + * cancels a {@link Future}, which interrupts the worker, and progress + * reporting is where the work notices. + * + * @author Curtis Rueden + */ +public class CancellationTest { + + /** A long-running "op": a plain Function, which cannot throw. */ + private static class CountingOp implements Function { + + final CountDownLatch started = new CountDownLatch(1); + volatile long completed = 0; + + @Override + public Long apply(final Long iterations) { + Progress.register(this); + Progress.defineTotal(iterations); + for (long i = 0; i < iterations; i++) { + // NB: the checkpoint is free, because this op already reports. + Progress.update(); + completed = i + 1; + started.countDown(); + try { + Thread.sleep(1); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + } + Progress.complete(); + return completed; + } + } + + /** Future.cancel(true) stops an op that reports progress. */ + @Test + public void testFutureCancelStopsTheOp() throws Exception { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final CountingOp op = new CountingOp(); + final Future future = executor.submit(() -> op.apply(1_000_000L)); + assertTrue(op.started.await(5, TimeUnit.SECONDS)); + + assertTrue(future.cancel(true)); + assertThrows(CancellationException.class, () -> future.get(5, + TimeUnit.SECONDS)); + + // The op stopped early rather than running a million iterations. + Thread.sleep(50); + final long stoppedAt = op.completed; + assertTrue(stoppedAt < 1_000_000L, "op ran to completion"); + } + finally { + executor.shutdownNow(); + } + } + + /** The checkpoint throws, and leaves the interrupt flag set. */ + @Test + public void testCheckCancellationRestoresTheFlag() throws Exception { + final AtomicReference thrown = + new AtomicReference<>(); + final AtomicBoolean flagStillSet = new AtomicBoolean(); + final Thread worker = new Thread(() -> { + Thread.currentThread().interrupt(); + try { + Progress.checkCancellation(); + } + catch (final CancellationException exc) { + thrown.set(exc); + flagStillSet.set(Thread.currentThread().isInterrupted()); + } + }); + worker.start(); + worker.join(5000); + + assertNotNull(thrown.get(), "checkCancellation did not throw"); + assertTrue(flagStillSet.get(), "the interrupt flag must be restored"); + } + + /** An uninterrupted thread passes the checkpoint untouched. */ + @Test + public void testCheckCancellationIsANoOpNormally() { + Progress.checkCancellation(); + assertFalse(Thread.currentThread().isInterrupted()); + } + + /** + * An op cancelled outside a Future still aborts, and the exception reaches + * the caller rather than being swallowed. + */ + @Test + public void testDirectInterruptionAborts() throws Exception { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final CountingOp op = new CountingOp(); + final AtomicReference workerThread = new AtomicReference<>(); + final Future future = executor.submit(() -> { + workerThread.set(Thread.currentThread()); + return op.apply(1_000_000L); + }); + assertTrue(op.started.await(5, TimeUnit.SECONDS)); + + // NB: interrupt the thread directly, without cancelling the future. + workerThread.get().interrupt(); + + final ExecutionException exc = assertThrows(ExecutionException.class, + () -> future.get(5, TimeUnit.SECONDS)); + assertTrue(exc.getCause() instanceof CancellationException, + "expected a CancellationException, got: " + exc.getCause()); + } + finally { + executor.shutdownNow(); + } + } +} diff --git a/scijava-progress/src/test/java/org/scijava/progress/DefaultProgressTest.java b/progress/src/test/java/org/scijava/progress/DefaultProgressTest.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/DefaultProgressTest.java rename to progress/src/test/java/org/scijava/progress/DefaultProgressTest.java diff --git a/scijava-progress/src/test/java/org/scijava/progress/ImproperReportingTest.java b/progress/src/test/java/org/scijava/progress/ImproperReportingTest.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/ImproperReportingTest.java rename to progress/src/test/java/org/scijava/progress/ImproperReportingTest.java diff --git a/scijava-progress/src/test/java/org/scijava/progress/ParallelProgressTest.java b/progress/src/test/java/org/scijava/progress/ParallelProgressTest.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/ParallelProgressTest.java rename to progress/src/test/java/org/scijava/progress/ParallelProgressTest.java diff --git a/scijava-progress/src/test/java/org/scijava/progress/StatusBasedProgressTest.java b/progress/src/test/java/org/scijava/progress/StatusBasedProgressTest.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/StatusBasedProgressTest.java rename to progress/src/test/java/org/scijava/progress/StatusBasedProgressTest.java diff --git a/scijava-progress/src/test/java/org/scijava/progress/SubtaskProgressTest.java b/progress/src/test/java/org/scijava/progress/SubtaskProgressTest.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/SubtaskProgressTest.java rename to progress/src/test/java/org/scijava/progress/SubtaskProgressTest.java diff --git a/scijava-progress/src/test/java/org/scijava/progress/TestSuiteTaskConsumer.java b/progress/src/test/java/org/scijava/progress/TestSuiteTaskConsumer.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/TestSuiteTaskConsumer.java rename to progress/src/test/java/org/scijava/progress/TestSuiteTaskConsumer.java diff --git a/scijava-collections/README.md b/scijava-collections/README.md deleted file mode 100644 index 23581cf0c..000000000 --- a/scijava-collections/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# SciJava Collections - -Useful implementions of `java.util.Collection`, e.g. for primitive arrays. - -Not at full-featured as Trove, but simple. diff --git a/scijava-discovery-test/README.md b/scijava-discovery-test/README.md deleted file mode 100644 index a95cab21c..000000000 --- a/scijava-discovery-test/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# SciJava Ops Service Loader: A [`ServiceLoader`](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/ServiceLoader.html) extension of SciJava Discovery -This module provides an implementation of `Discoverer` that is powered by the `ServiceLoader` from Java's Module System. - -## What can `ServiceLoaderDiscoverer` discover? - -`ServiceLoaderDiscoverer` can discover implementations of *any* interface declared to be `use`d in its `module-info.java`. Although we limit the `use`d interfaces to those defined in SciJava Ops SPI (this is, after all, an Ops project), the process is the same for any other interface. - -# I wrote an implementation, how can I make it discoverable via `ServiceLoaderDiscoverer`? - -Suppose your module `com.example.foo` contains an implementation `com.example.foo.FooOp` of `org.scijava.ops.spi.Op`, and you wise to make `FooOp` discoverable from `ServiceLoaderDiscoverer`. Make sure to: - -* Declare SciJava Ops Service Loader as a dependency; if using Maven, it looks like this: - -```xml - - org.scijava - scijava-ops-serviceLoader - 1.0.0 - -``` -* Make sure that the interface being implemented is `use`d in SciJava Ops Service Loader's `module-info.java` -* Declare in your project's `module-info.java`: - -```java -provides org.scijava.ops.spi.Op with com.example.foo.FooOp; -``` \ No newline at end of file diff --git a/scijava-discovery/README.md b/scijava-discovery/README.md deleted file mode 100644 index 2f8897fcf..000000000 --- a/scijava-discovery/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# SciJava Discovery: A library for the abstraction of service discovery mechanisms - -This module provides the `Discoverer` interface, an abstraction for service discovery. `Discoverer`s are designed to return `Discovery` objects, which combine the discovered `Object` with a tag (explained below). `Discoverer`s should implement one (or both) of `Discoverer`'s methods: - -## `List>> Discoverer.implsOfType(Class c)` - -This method is designed to return a list of implementations of some `Class c`. In other words, `c` is a superclass extended by/interface implemented by each of the returned `Class`es. - -## `List> Discoverer.elementsTaggedWith(String tagType)` - -This method is designed to return a list of `AnnotatedElements` (`Class`es, `Method`s, or `Field`s) tagged with the given tag type. - -## Tags - -** TODO** diff --git a/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java b/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java deleted file mode 100644 index 5779ef3fa..000000000 --- a/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java +++ /dev/null @@ -1,220 +0,0 @@ -/*- - * #%L - * Plugin discovery subsystem for SciJava libraries. - * %% - * Copyright (C) 2021 - 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.discovery; - -import java.lang.reflect.Type; -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; - -/** - * Discovers implementations of a given type. - * - * @author Gabriel Selzer - */ -@FunctionalInterface -public interface Discoverer { - - /** - * Discovers implementations of some {@link Class} {@code c}. - * - * @param the {@link Type} of the {@link Class} being searched for - * @param c the {@link Class} being searched for - * @return a {@link List} of implementations of {@code c} - */ - List discover(Class c); - - /** - * Creates a {@link Discoverer} operating via {@link Function}. - *

- * NB: The {@link Function} input is extremely important for e.g. JPMS - * compatibility. This puts the code loading the services into the user's - * module, allowing access to all of the services exposed to (and {@code use}d - * by) the calling module. Otherwise, all service interfaces would have to be - * {@code use}d by this module, which is not extensible. - *

- * - * @param func the {@link Function} generating a {@link Iterable} of - * implementations provided a {@link Class} - * @param the {@link Class} we attempt to discover, and consequently the - * supertype of all implementations contained in the {@link Iterable} - * @return A {@link Discoverer} backed by {@code func} - */ - static Discoverer using(Function, ? extends Iterable> func) { - return new Discoverer() { - - @Override - public List discover(Class c) { - // If we can use c, look up the implementations - try { - var itr = (Iterable) func.apply((Class) c); - return StreamSupport.stream(itr.spliterator(), false).collect( - Collectors.toList()); - } - catch (ClassCastException | ServiceConfigurationError e) { - return Collections.emptyList(); - } - } - }; - } - - /** - * Gets all {@link Discoverer}s made available through {@link Iterable}, as - * well as a {@link Discoverer} that is itself backed by the {@link Iterable}. - *

- * It is highly recommended to call this method using {@code - * List discoverers = Discoverers.all(ServiceLoader::load); - * } - * - * @param func the {@link Function} generating a {@link Iterable} of - * implementations provided a {@link Class}. Notably, this callback - * has the module scope of the caller, which is useful for - * circumnavigating module permissions when using e.g. JPMS. If we - * instead used {@link ServiceLoader#load(Class)} directly, we'd only - * be able to discover implementations whose interface was - * {@code use}d by {@code module org.scijava.discovery}. - *

- * It is in the user's best interest to make this {@link Function} as - * general as possible. - * @param the {@link Class} we attempt to discover, and consequently the - * supertype of all implementations contained in the {@link Iterable} - * @return A {@link List} of {@link Discoverer}s, including a - * {@code Discoverer} backed by {@code func}, and all - * {@link Discoverer}s found by {@code func} - */ - static List all( - Function, ? extends Iterable> func) - { - // First, create the general-purpose Discoverer using the - // using(Function<...>) - // method - var d = using(func); - - // Then, use that Discoverer to discoverer all Discoverers provided to the - // caller module - Collection allProvided = d.discover(Discoverer.class); - - // append the general purpose discoverer to the discovered Discoverers, and - // return that. - List discoverers = new ArrayList<>(allProvided); - discoverers.add(d); - return discoverers; - } - - /** - * Accumulates multiple {@link Discoverer}s into one mega-{@code Discoverer} - * - * @param discoverers the {@link Discoverer}s to be wrapped - * @return the mega-{@link Discoverer} - */ - static Discoverer union(Iterable discoverers) { - - return new Discoverer() { - - @Override - public List discover(Class c) { - List list = new ArrayList<>(); - for (var discoverer : discoverers) { - list.addAll(discoverer.discover(c)); - } - return list; - } - }; - } - - /** - * Wraps up this {@code Discoverer} into a {@link Discoverer} that only - * discoverers classes {@code classes} - * - * @param classes the {@link Class}es - * @return the wrapping - */ - default Discoverer onlyFor(Class... classes) { - var list = Arrays.asList(classes); - var d = this; - return new Discoverer() { - - @Override - public List discover(Class c) { - if (list.contains(c)) return d.discover(c); - return Collections.emptyList(); - } - }; - } - - /** - * Wraps up this {@code Discoverer} into a {@link Discoverer} that only - * discoverers classes {@code classes} - * - * @param classes the {@link Class}es - * @return the wrapping - */ - default Discoverer except(Class... classes) { - var list = Arrays.asList(classes); - var d = this; - return new Discoverer() { - - @Override - public List discover(Class c) { - if (list.contains(c)) return Collections.emptyList(); - return d.discover(c); - } - }; - } - - /** - * Finds the maximum implementation of any {@link Comparable} {@code c}. - * - * @param c the {@link Class}, extending {@link Comparable} that the returned - * implementation must implement - * @param the {@link Type} of {@code c} - * @return the maximum implementation of {@code c} - */ - default > Optional discoverMax(Class c) { - var discoveries = discover(c); - // NB: natural order sorts in ascending order - return discoveries.stream().max(Comparator.naturalOrder()); - } - - /** - * Finds the minimum implementation of any {@link Comparable} {@code c}. - * - * @param c the {@link Class}, extending {@link Comparable} that the returned - * implementation must implement - * @param the {@link Type} of {@code c} - * @return the minimum implementation of {@code c} - */ - default > Optional discoverMin(Class c) { - var discoveries = discover(c); - // NB: natural order sorts in ascending order - return discoveries.stream().min(Comparator.naturalOrder()); - } -} diff --git a/scijava-legacy/README.md b/scijava-legacy/README.md deleted file mode 100644 index 81580d001..000000000 --- a/scijava-legacy/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# SciJava Legacy: A backwards compatibility bridge with older SciJava code - -This library provides a bridge between current SciJava libraries -and previous generations (Java 8 / pre-JPMS) of SciJava/ImageJ2 code. - -Features include: - -* Integration of SciJava Ops into SciJava Common's application context - and module system, via an OpEnvironmentService and preprocessor plugin. - -* A SciJava Ops TypeExtractor for ImageJ2's Dataset image type. - -For Maven projects, this library can be used by adding to your `pom.xml`: - -```java - - org.scijava - scijava-legacy - 1.0.0 - -``` diff --git a/script3/pom.xml b/script3/pom.xml new file mode 100644 index 000000000..b4e0544b6 --- /dev/null +++ b/script3/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-script3 + + 3.0.0-SNAPSHOT + + SciJava Script + Running scripts as things that declare their inputs and outputs. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.script3 + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-convert3 + ${scijava-convert3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/script3/src/main/java/module-info.java b/script3/src/main/java/module-info.java new file mode 100644 index 000000000..04340cef0 --- /dev/null +++ b/script3/src/main/java/module-info.java @@ -0,0 +1,43 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.script3 { + + exports org.scijava.script3; + + requires transitive java.scripting; + requires transitive org.scijava.execute; + requires transitive org.scijava.struct; + requires org.scijava.common3; + requires org.scijava.convert3; + requires org.scijava.priority; + + uses org.scijava.script3.ScriptLanguage; + +} diff --git a/script3/src/main/java/org/scijava/script3/JsrLanguage.java b/script3/src/main/java/org/scijava/script3/JsrLanguage.java new file mode 100644 index 000000000..331620799 --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/JsrLanguage.java @@ -0,0 +1,144 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptEngineFactory; +import javax.script.ScriptEngineManager; + +import org.scijava.execute.Behavior; + +/** + * A language backed by a {@code javax.script} engine. + *

+ * NB: JSR-223 is the first backend rather than the only one. It is what + * Groovy, Jython, JRuby, Clojure, BeanShell and the Fiji script editor already + * speak, so it costs nothing and brings every existing language along; GraalVM + * polyglot and Appose are further {@link ScriptLanguage}s, not replacements + * for this one. + *

+ * + * @author Curtis Rueden + */ +public class JsrLanguage implements ScriptLanguage { + + private final ScriptEngineFactory factory; + + public JsrLanguage(final ScriptEngineFactory factory) { + this.factory = factory; + } + + /** Gets a language for every JSR-223 engine on the classpath. */ + public static List discover() { + final List languages = new ArrayList<>(); + for (final ScriptEngineFactory factory : new ScriptEngineManager() + .getEngineFactories()) + { + languages.add(new JsrLanguage(factory)); + } + return languages; + } + + @Override + public String name() { + return factory.getLanguageName(); + } + + @Override + public List extensions() { + return List.copyOf(factory.getExtensions()); + } + + @Override + public ScriptSession start() { + return new JsrSession(factory.getScriptEngine()); + } + + @Override + public String toString() { + return name() + " " + factory.getLanguageVersion(); + } + + /** A session backed by a {@link ScriptEngine}. */ + private static class JsrSession implements ScriptSession { + + private final ScriptEngine engine; + + JsrSession(final ScriptEngine engine) { + this.engine = engine; + } + + @Override + public void put(final String name, final Object value) { + engine.put(name, value); + } + + @Override + public Object get(final String name) { + return engine.get(name); + } + + @Override + public Object eval(final String code) { + try { + return engine.eval(code); + } + catch (final javax.script.ScriptException exc) { + throw new ScriptException("Script failed: " + exc.getMessage(), exc); + } + } + + @Override + public Optional function(final String name) { + if (!(engine instanceof Invocable)) return Optional.empty(); + final Invocable invocable = (Invocable) engine; + return Optional.of(args -> { + try { + return invocable.invokeFunction(name, args); + } + catch (final NoSuchMethodException exc) { + // NB: the script declared a callback it did not define. That is + // the script's bug, and saying so beats doing nothing quietly. + throw new ScriptException("No such function in script: " + name, + exc); + } + catch (final javax.script.ScriptException exc) { + throw new ScriptException("Script function '" + name + + "' failed: " + exc.getMessage(), exc); + } + }); + } + } +} diff --git a/script3/src/main/java/org/scijava/script3/ScriptException.java b/script3/src/main/java/org/scijava/script3/ScriptException.java new file mode 100644 index 000000000..0d62893b8 --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/ScriptException.java @@ -0,0 +1,53 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +/** + * Thrown when a script fails: it would not parse, or it threw. + *

+ * NB: unchecked, deliberately. A script failing is an ordinary outcome for the + * code that runs it - it reports the message to the user - and forcing every + * intermediate layer to declare it only adds noise. + *

+ * + * @author Curtis Rueden + */ +public class ScriptException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ScriptException(final String message) { + super(message); + } + + public ScriptException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/script3/src/main/java/org/scijava/script3/ScriptExecutable.java b/script3/src/main/java/org/scijava/script3/ScriptExecutable.java new file mode 100644 index 000000000..dfb41e6a0 --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/ScriptExecutable.java @@ -0,0 +1,231 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.util.Map; +import java.util.Optional; + +import org.scijava.convert3.Converters; +import org.scijava.execute.Behavior; +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.Parameters; +import org.scijava.struct.ItemIO; +import org.scijava.struct.Struct; +import org.scijava.struct.StructInstance; + +/** + * A script, as something that declares its inputs and outputs. + *

+ * This is the whole point of the scripting layer: a script is an + * {@link Executable} like any other, so it runs through the same + * {@code Runner}, is preprocessed by the same preprocessors, harvests its + * inputs through the same dialog, and can sit in a menu beside a Java command. + * Nothing above this layer needs to know which it is. + *

+ *

+ * NB: its parameters are built over a map rather than reflected off fields, + * because a script has no fields - which is the same primitive a generated + * parameter group needs, and why {@code Parameters.builder()} exists. + *

+ * + * @author Curtis Rueden + */ +public class ScriptExecutable implements Executable { + + private final String name; + private final String code; + private final ScriptLanguage language; + private final ScriptHeader header; + + public ScriptExecutable(final String name, final String code, + final ScriptLanguage language) + { + this.name = name; + this.code = code; + this.language = language; + this.header = ScriptHeader.parse(code); + } + + @Override + public String name() { + return name; + } + + /** Gets the language this script is written in. */ + public ScriptLanguage language() { + return language; + } + + /** Gets what the script declares about itself. */ + public ScriptHeader header() { + return header; + } + + /** Gets the script's source. */ + public String code() { + return code; + } + + @Override + public Struct struct() { + return build().struct(); + } + + @Override + public ExecutableInstance create() { + return new Instance(build()); + } + + @Override + public String toString() { + return name + " [" + language.name() + "]"; + } + + // -- Helper methods -- + + /** Builds the parameters the header declares, over a fresh map. */ + private StructInstance> build() { + final Parameters.Builder builder = Parameters.builder(); + for (final ScriptHeader.Parameter p : header.parameters()) { + builder.add(p.name(), p.type(), p.io(), p.attrs(), defaultValue(p)); + } + return builder.build(); + } + + /** Gets a parameter's declared default, converted to its type. */ + private static Object defaultValue(final ScriptHeader.Parameter p) { + final String value = p.attrs().get("value"); + if (value == null) return null; + return Converters.get().tryConvert(value, p.type()).orElse(null); + } + + /** One run of the script: its own session, its own values. */ + private class Instance implements ExecutableInstance { + + private final StructInstance> parameters; + private ScriptSession session; + + Instance(final StructInstance> parameters) { + this.parameters = parameters; + } + + @Override + public Executable executable() { + return ScriptExecutable.this; + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + final ScriptSession s = session(); + s.eval(header.body()); + sync(); + } + + @Override + public Optional behavior(final String name) { + // NB: a name, resolved by the kind of code this is: here, a function + // the script defined. The script must have been evaluated for its + // functions to exist, so the session primes itself with the script on + // first use. + return session().function(name).map(behavior -> args -> { + final Object result = behavior.invoke(args); + // NB: and whatever the function changed comes back. A callback + // exists precisely to change other parameters, so a behavior whose + // effects stayed inside the interpreter would be useless. + sync(); + return result; + }); + } + + /** + * Copies the script's variables back into the parameters. + *

+ * NB: converted, because a language has its own ideas about types - + * Groovy's {@code celsius * 9 / 5 + 32} is a {@code BigDecimal} - and the + * parameter's declared type is the authority on what its value is. + *

+ */ + private void sync() { + final ScriptSession s = session; + if (s == null) return; + for (final ScriptHeader.Parameter p : header.parameters()) { + final Object value = s.get(p.name()); + if (value == null) continue; + final Object converted = Converters.get().tryConvert(value, + (java.lang.reflect.Type) p.type()).orElse(value); + parameters.object().put(p.name(), converted); + } + } + + /** Gets this run's session, starting it on first use. */ + private synchronized ScriptSession session() { + if (session == null) { + session = language.start(); + bind(); + // NB: evaluated once so that its functions exist; running it again + // is what run() does, with the values the user supplied. + try { + session.eval(header.body()); + } + catch (final ScriptException exc) { + // NB: a script that cannot run before its inputs are filled in + // is ordinary -- it is about to be given them. Callbacks it + // declared will simply not resolve until it does run. + } + } + else bind(); + return session; + } + + /** + * Hands the current parameter values to the session. + *

+ * NB: every declared parameter is bound, including the ones with no + * value yet. A language that resolves names at run time - which is most + * of them - fails on an unbound name rather than seeing null, so a + * function referring to a parameter the user has not filled in would + * blow up instead of behaving. + *

+ */ + private void bind() { + final Map values = parameters.object(); + for (final ScriptHeader.Parameter p : header.parameters()) { + session.put(p.name(), values.get(p.name())); + } + } + } + +} diff --git a/script3/src/main/java/org/scijava/script3/ScriptFinder.java b/script3/src/main/java/org/scijava/script3/ScriptFinder.java new file mode 100644 index 000000000..4cc8dd86c --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/ScriptFinder.java @@ -0,0 +1,162 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Finds the scripts in a directory, and what each says about itself. + *

+ * A script declares its presentation the way a command does, in a + * {@code #@script} directive: + *

+ * + *
+ * #@script(menu = "Process>Filters>Blur It", accelerator = "^B")
+ * 
+ *

+ * and where it says nothing, the directory it sits in speaks for it: + * {@code scripts/Process/Filters/Blur_It.groovy} means the same thing, with + * underscores read as spaces and the extension dropped - which is how the tens + * of thousands of scripts already in the wild are arranged. + *

+ *

+ * NB: the metadata comes back as a map rather than as a menu entry, because + * this component knows nothing about menus. An application turns it into one + * with {@code CommandInfo.of(script, metadata)}, which is the same call any + * other source of runnable things makes. + *

+ * + * @author Curtis Rueden + */ +public class ScriptFinder { + + /** A script that was found, and what it says about itself. */ + public static class Found { + + private final ScriptExecutable script; + private final Map metadata; + + Found(final ScriptExecutable script, final Map metadata) { + this.script = script; + this.metadata = metadata; + } + + /** Gets the script, ready to run. */ + public ScriptExecutable script() { + return script; + } + + /** Gets its presentation metadata: menu path, label, accelerator. */ + public Map metadata() { + return Map.copyOf(metadata); + } + + @Override + public String toString() { + return script.name() + " " + metadata; + } + } + + private final Scripts scripts; + + public ScriptFinder() { + this(Scripts.get()); + } + + public ScriptFinder(final Scripts scripts) { + this.scripts = scripts; + } + + /** + * Finds every script beneath the given directory. + *

+ * A file whose extension no available language claims is skipped, not + * fatal: a scripts directory routinely holds text files, images and + * languages this installation happens not to have. + *

+ * + * @param directory the directory to search + * @return what was found, in directory order + */ + public List find(final Path directory) { + if (!Files.isDirectory(directory)) return List.of(); + try (Stream paths = Files.walk(directory)) { + return paths.filter(Files::isRegularFile) // + .map(path -> found(directory, path)) // + .filter(found -> found != null) // + .collect(Collectors.toList()); + } + catch (final IOException exc) { + throw new UncheckedIOException("Cannot search for scripts: " + directory, + exc); + } + } + + // -- Helper methods -- + + private Found found(final Path root, final Path path) { + final ScriptExecutable script; + try { + script = scripts.of(path); + } + catch (final ScriptException exc) { + return null; // NB: not a script, or not a language we have + } + final Map metadata = new LinkedHashMap<>(script.header() + .directives()); + metadata.putIfAbsent("menu", menuPath(root, path)); + return new Found(script, metadata); + } + + /** Reads a menu path off the directory the script sits in. */ + private static String menuPath(final Path root, final Path path) { + final List elements = new ArrayList<>(); + for (final Path element : root.relativize(path)) { + elements.add(element.toString()); + } + // NB: the file name itself is the last element, without its extension. + final int last = elements.size() - 1; + final String fileName = elements.get(last); + final int dot = fileName.lastIndexOf('.'); + elements.set(last, dot < 0 ? fileName : fileName.substring(0, dot)); + return elements.stream().map(e -> e.replace('_', ' ')) // + .collect(Collectors.joining(">")); + } +} diff --git a/script3/src/main/java/org/scijava/script3/ScriptHeader.java b/script3/src/main/java/org/scijava/script3/ScriptHeader.java new file mode 100644 index 000000000..d9798af4a --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/ScriptHeader.java @@ -0,0 +1,346 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.scijava.common3.Classes; +import org.scijava.struct.ItemIO; + +/** + * What a script declares about itself, read from its {@code #@} lines. + *

+ * The syntax is SciJava Common's, because tens of thousands of scripts are + * written in it: + *

+ * + *
+ * #@ String name
+ * #@ String(label = "Your name", value = "ada") name
+ * #@ double(min = 0, max = 1) weight
+ * #@ File(style = "directory") folder
+ * #@output String greeting
+ * #@script(menu = "Plugins>My Script")
+ * 
+ *

+ * The attributes are the same ones a Java {@code @Parameter} carries - label, + * callback, validator, choices, min, max, style - and mean the same things, + * which is the point: a script and a command differ in the language they are + * written in and in nothing else. + *

+ * + * @author Curtis Rueden + */ +public class ScriptHeader { + + /** A parameter a script declares. */ + public static class Parameter { + + private final String name; + private final Class type; + private final ItemIO io; + private final Map attrs; + + Parameter(final String name, final Class type, final ItemIO io, + final Map attrs) + { + this.name = name; + this.type = type; + this.io = io; + this.attrs = attrs; + } + + public String name() { + return name; + } + + public Class type() { + return type; + } + + public ItemIO io() { + return io; + } + + public Map attrs() { + return attrs; + } + + @Override + public String toString() { + return io + " " + type.getSimpleName() + " " + name + " " + attrs; + } + } + + // NB: the attributes may attach to the direction or to the type -- + // `#@input(persist = false) boolean verbose` and + // `#@ String(label = "Name") name` are both in the wild -- so the line is + // taken apart rather than matched in one go. + private static final Pattern LINE = Pattern.compile("^\\s*#@\\s*(\\S.*)$"); + + // NB: a script may be executable in its own right. + private static final Pattern SHEBANG = Pattern.compile("^#!(.*)$"); + + private static final Pattern DIRECTION = Pattern.compile( + "^(input|output|both)\\b(.*)$"); + + private static final Pattern DIRECTIVE = Pattern.compile( + "^\\s*#@script\\s*\\(([^)]*)\\)\\s*$"); + + private final List parameters = new ArrayList<>(); + private final Map directives = new LinkedHashMap<>(); + private String shebang; + private String body; + + /** Reads what the given script declares. */ + public static ScriptHeader parse(final String code) { + final ScriptHeader header = new ScriptHeader(); + final String[] lines = code.split("\r?\n", -1); + for (int i = 0; i < lines.length; i++) { + final String line = lines[i]; + final boolean declaration; + final Matcher directive = DIRECTIVE.matcher(line); + if (directive.matches()) { + header.directives.putAll(attrs(directive.group(1))); + declaration = true; + } + else { + final Matcher matcher = LINE.matcher(line); + declaration = matcher.matches(); + if (declaration) header.declare(matcher.group(1)); + } + // NB: a declaration is blanked rather than removed, so that the line + // numbers in the language's own error messages still point at the + // line the author is looking at. `#@` is a comment in some languages + // and a syntax error in others, so leaving it in is not an option. + final Matcher shebang = SHEBANG.matcher(line); + if (i == 0 && shebang.matches()) { + // NB: as SciJava Common reads it: everything after #! names the + // language, so `#!jython` and `#!/usr/bin/env jython` both say + // Jython. A path is taken apart because that is how people write + // shebangs everywhere else. + final String said = shebang.group(1).trim(); + final String[] words = said.split("[\\s/]+"); + header.shebang = words.length == 0 ? said : words[words.length - 1]; + lines[i] = ""; + continue; + } + if (declaration) lines[i] = ""; + } + header.body = String.join("\n", lines); + return header; + } + + /** + * Gets the script with its declarations blanked out: what the language + * itself is asked to run. + */ + public String body() { + return body; + } + + /** Gets the parameters the script declares, in the order it declares them. */ + public List parameters() { + return List.copyOf(parameters); + } + + /** + * Gets what the script says about itself: where it belongs in the menus, + * what to call it, what shortcut it answers to. + *

+ * These are the {@code #@script(...)} keys, and they are the same names a + * {@code @Menu} annotation uses, so + * {@code org.scijava.command3.CommandInfo.of} turns them into a menu entry + * directly. + *

+ */ + public Map directives() { + return Map.copyOf(directives); + } + + /** + * Gets the language this script says it is written in, if it says. + *

+ * Two ways of saying it, because both are in use: {@code #@script(language + * = "jython")}, and a shebang line. This is what disambiguates the + * languages that share an extension - {@code .py} being Jython or Python + * depending on which is meant - and a script that says nothing is read + * according to its extension, as before. + *

+ */ + public Optional language() { + final String declared = directives.get("language"); + if (declared != null && !declared.isEmpty()) return Optional.of(declared); + return Optional.ofNullable(shebang); + } + + // -- Helper methods -- + + private void declare(final String declaration) { + String rest = declaration.trim(); + + // the direction, if stated: `#@output`, `#@both` + String io = null; + final Matcher direction = DIRECTION.matcher(rest); + if (direction.matches()) { + io = direction.group(1); + rest = direction.group(2).trim(); + } + + // the attributes, wherever they were attached + Map attrs = Map.of(); + final int open = rest.indexOf('('); + if (open >= 0) { + final int close = rest.lastIndexOf(')'); + if (close < open) { + throw new ScriptException("Unclosed attributes in: #@" + declaration); + } + attrs = attrs(rest.substring(open + 1, close)); + rest = (rest.substring(0, open) + " " + rest.substring(close + 1)) + .trim(); + } + + // what is left is the type and the name, or just the name + final String[] words = rest.split("\\s+"); + if (words.length == 0 || words[0].isEmpty()) return; // nothing declared + if (words.length > 2) { + throw new ScriptException("Cannot read script parameter: #@" + + declaration); + } + final String name = words[words.length - 1]; + final Class type = words.length == 1 ? Object.class : type(words[0]); + if (type == null) { + throw new ScriptException("No such type in script parameter: " + + words[0]); + } + parameters.add(new Parameter(name, type, io(io), attrs)); + } + + private static ItemIO io(final String io) { + if (io == null || io.equals("input")) return ItemIO.INPUT; + if (io.equals("output")) return ItemIO.OUTPUT; + // NB: SciJava Common's `both` is this: the value goes in, and what the + // script leaves behind comes out. + return ItemIO.MUTABLE; + } + + /** Resolves a type name, allowing the short names Java itself allows. */ + private static Class type(final String name) { + switch (name) { + case "boolean": + return boolean.class; + case "byte": + return byte.class; + case "char": + return char.class; + case "short": + return short.class; + case "int": + return int.class; + case "long": + return long.class; + case "float": + return float.class; + case "double": + return double.class; + default: + break; + } + final Class direct = Classes.load(name, true); + if (direct != null) return direct; + // NB: the names a script writer actually types are unqualified. + for (final String pkg : new String[] { "java.lang.", "java.util.", + "java.io.", "java.math.", "java.nio.file." }) + { + final Class c = Classes.load(pkg + name, true); + if (c != null) return c; + } + return null; + } + + /** Reads {@code a = 1, b = "two"} into a map. */ + private static Map attrs(final String text) { + final Map attrs = new LinkedHashMap<>(); + if (text == null || text.isBlank()) return attrs; + for (final String pair : split(text)) { + final int equals = pair.indexOf('='); + if (equals < 0) { + // NB: a bare word is a flag: `#@ boolean(persist) verbose`. + attrs.put(pair.trim(), "true"); + continue; + } + attrs.put(pair.substring(0, equals).trim(), unquote(pair.substring( + equals + 1).trim())); + } + return attrs; + } + + /** Splits on commas, except inside quotes, where a comma is a comma. */ + private static List split(final String text) { + final List parts = new ArrayList<>(); + final StringBuilder part = new StringBuilder(); + char quote = 0; + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if (quote != 0) { + if (c == quote) quote = 0; + part.append(c); + } + else if (c == '"' || c == '\'') { + quote = c; + part.append(c); + } + else if (c == ',') { + parts.add(part.toString()); + part.setLength(0); + } + else part.append(c); + } + if (part.length() > 0) parts.add(part.toString()); + return parts; + } + + private static String unquote(final String value) { + if (value.length() < 2) return value; + final char first = value.charAt(0); + if ((first == '"' || first == '\'') && value.charAt(value.length() - 1) == + first) + { + return value.substring(1, value.length() - 1); + } + return value; + } +} diff --git a/script3/src/main/java/org/scijava/script3/ScriptLanguage.java b/script3/src/main/java/org/scijava/script3/ScriptLanguage.java new file mode 100644 index 000000000..b31358163 --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/ScriptLanguage.java @@ -0,0 +1,66 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.util.List; + +import org.scijava.priority.Priority; + +/** + * A language scripts can be written in. + *

+ * Contribute one through {@link java.util.ServiceLoader}. The JSR-223 engines + * on the classpath are already contributed - see {@code JsrLanguages} - so a + * language with a {@code javax.script} engine needs nothing at all; this + * interface exists for the ones that do not, GraalVM's polyglot languages and + * Appose's out-of-process interpreters being the cases in view. + *

+ * + * @author Curtis Rueden + */ +public interface ScriptLanguage { + + /** Gets this language's name, as a user would say it: "Groovy". */ + String name(); + + /** Gets the file extensions scripts in this language use, without dots. */ + List extensions(); + + /** + * Starts a session: an interpreter with its own state, in which a script + * runs and its functions can afterwards be called. + */ + ScriptSession start(); + + /** Sorts languages claiming the same extension, highest first. */ + default double priority() { + return Priority.NORMAL; + } +} diff --git a/script3/src/main/java/org/scijava/script3/ScriptSession.java b/script3/src/main/java/org/scijava/script3/ScriptSession.java new file mode 100644 index 000000000..9561563ff --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/ScriptSession.java @@ -0,0 +1,80 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.util.Optional; + +import org.scijava.execute.Behavior; + +/** + * An interpreter with its own state, in which a script runs. + *

+ * NB: {@link #function} is the reason this is a session rather than a + * one-shot {@code eval}. A script's parameters may name callbacks, validators + * and generators of choices, and those are functions in the script; + * resolving them means keeping the interpreter that defined them. SciJava + * Common resolved such names by Java reflection, which is precisely why its + * scripts never had callbacks. + *

+ * + * @author Curtis Rueden + */ +public interface ScriptSession extends AutoCloseable { + + /** Sets a variable the script can read. */ + void put(String name, Object value); + + /** Gets a variable the script has set, or null if it has none. */ + Object get(String name); + + /** + * Runs the given code in this session. + * + * @param code the script + * @return whatever the script evaluated to, if anything + * @throws ScriptException if the script fails + */ + Object eval(String code); + + /** + * Gets a function the script has defined, so that it can be called. + * + * @param name the function's name + * @return the function, or empty if the script defines no such thing + */ + default Optional function(final String name) { + return Optional.empty(); + } + + @Override + default void close() { + // NB: most sessions have nothing to release. + } +} diff --git a/script3/src/main/java/org/scijava/script3/Scripts.java b/script3/src/main/java/org/scijava/script3/Scripts.java new file mode 100644 index 000000000..f70c844c2 --- /dev/null +++ b/script3/src/main/java/org/scijava/script3/Scripts.java @@ -0,0 +1,200 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.ServiceLoader; + +/** + * Finds the languages available, and makes scripts runnable. + *

+ * Languages come from {@link ServiceLoader} and from the JSR-223 engines on + * the classpath, so this needs no container and no configuration. For an + * embedded or tested setting where implicit discovery is unwanted, construct + * an instance with an explicit list instead. + *

+ * + * @author Curtis Rueden + */ +public class Scripts { + + private static class DefaultHolder { + + static final Scripts INSTANCE = new Scripts(discoverLanguages()); + } + + private final List languages; + + /** Creates an instance that uses exactly the given languages. */ + public Scripts(final List languages) { + this.languages = new ArrayList<>(languages); + // NB: highest priority first, so the first claiming an extension wins. + this.languages.sort(Comparator.comparingDouble( + (ScriptLanguage l) -> l.priority()).reversed()); + } + + /** Gets the shared instance, backed by discovery. */ + public static Scripts get() { + return DefaultHolder.INSTANCE; + } + + /** Gets the languages available, highest priority first. */ + public List languages() { + return List.copyOf(languages); + } + + /** + * Finds a language by name or by file extension, either spelled any way. + * + * @param nameOrExtension "Groovy", "groovy", or ".groovy" + * @return the language, or empty if nothing here speaks it + */ + public Optional language(final String nameOrExtension) { + if (nameOrExtension == null) return Optional.empty(); + final String key = nameOrExtension.toLowerCase(Locale.ROOT) // + .replaceFirst("^\\.", ""); + return languages.stream().filter(l -> // + l.name().toLowerCase(Locale.ROOT).equals(key) || l.extensions().stream() + .anyMatch(e -> e.toLowerCase(Locale.ROOT).equals(key))).findFirst(); + } + + /** + * Reads a script file as something that declares its inputs and outputs. + *

+ * The language comes from the file's extension. + *

+ * + * @param path the script to read + * @return the script, ready to run through a {@code Runner} + * @throws ScriptException if the file cannot be read, or nothing here + * speaks its language + */ + public ScriptExecutable of(final Path path) { + final String fileName = path.getFileName().toString(); + final String code; + try { + code = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + catch (final IOException exc) { + throw new ScriptException("Cannot read script: " + path, exc); + } + return of(fileName, code, languageOf(fileName, code)); + } + + /** + * Takes script source as something that declares its inputs and outputs, + * with the language the script itself names. + * + * @param name what to call it + * @param code the script, which may name its language + * @return the script, ready to run through a {@code Runner} + * @throws ScriptException if nothing here speaks its language + */ + public ScriptExecutable of(final String name, final String code) { + return of(name, code, languageOf(name, code)); + } + + /** + * Works out which language a script is written in. + *

+ * What the script says wins over what its extension implies: a + * {@code #@script(language = "jython")} directive or a {@code #!} line + * settles which of the languages sharing {@code .py} is meant. An + * extension no language claims is an error naming what is available, + * rather than a silent failure to run. + *

+ * + * @param name the script's file name, whose extension is the fallback + * @param code the script itself + * @return the language it is written in + */ + public ScriptLanguage languageOf(final String name, final String code) { + final Optional declared = ScriptHeader.parse(code).language(); + if (declared.isPresent()) { + return language(declared.get()).orElseThrow(() -> new ScriptException( + "No such language: " + declared.get() + ". Available: " + names())); + } + final int dot = name.lastIndexOf('.'); + final String extension = dot < 0 ? "" : name.substring(dot + 1); + return language(extension).orElseThrow(() -> new ScriptException( + "No language for '." + extension + "'. Available: " + names())); + } + + /** + * Takes script source as something that declares its inputs and outputs. + * + * @param name what to call it + * @param code the script + * @param nameOrExtension which language it is written in + * @return the script, ready to run through a {@code Runner} + * @throws ScriptException if nothing here speaks that language + */ + public ScriptExecutable of(final String name, final String code, + final String nameOrExtension) + { + return of(name, code, language(nameOrExtension).orElseThrow( + () -> new ScriptException("No such language: " + nameOrExtension + + ". Available: " + names()))); + } + + /** Takes script source, in the given language. */ + public ScriptExecutable of(final String name, final String code, + final ScriptLanguage language) + { + return new ScriptExecutable(name, code, language); + } + + // -- Helper methods -- + + private String names() { + return languages.stream().map(ScriptLanguage::name).collect(java.util.stream + .Collectors.joining(", ")); + } + + private static List discoverLanguages() { + final List found = new ArrayList<>(); + // NB: loaded here, in this module, because ServiceLoader resolves `uses` + // against the calling module. + ServiceLoader.load(ScriptLanguage.class).forEach(found::add); + // NB: and every JSR-223 engine, which is how an existing language - + // Groovy, Jython, Clojure - becomes available by being on the classpath + // and doing nothing else at all. + found.addAll(JsrLanguage.discover()); + return found; + } +} diff --git a/script3/src/test/java/org/scijava/script3/GroovyScriptTest.java b/script3/src/test/java/org/scijava/script3/GroovyScriptTest.java new file mode 100644 index 000000000..74173cec4 --- /dev/null +++ b/script3/src/test/java/org/scijava/script3/GroovyScriptTest.java @@ -0,0 +1,212 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.Test; +import org.scijava.execute.Behavior; +import org.scijava.execute.ExecutableInstance; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Preprocessor; +import org.scijava.execute.Runner; + +/** + * Tests a script as an executable, against a real language. + *

+ * Groovy is the engine here, but nothing in these tests is about Groovy: they + * are about a script being a thing that declares its inputs and outputs, and + * therefore running the way everything else does. + *

+ * + * @author Curtis Rueden + */ +public class GroovyScriptTest { + + private final Scripts scripts = Scripts.get(); + + /** Groovy is found through its JSR-223 engine, with nothing declared. */ + @Test + public void testLanguageDiscovery() { + final Optional groovy = scripts.language("groovy"); + assertTrue(groovy.isPresent(), "available languages: " + scripts + .languages()); + assertTrue(scripts.language(".groovy").isPresent(), "extension with a dot"); + assertTrue(scripts.language("Groovy").isPresent(), "name, as a user says"); + } + + /** A script's declared inputs and outputs are its parameters. */ + @Test + public void testScriptIsAnExecutable() { + final ScriptExecutable script = greeting(); + + assertEquals(List.of("name", "shout", "greeting"), script.struct() + .members().stream().map(m -> m.key()).collect(java.util.stream.Collectors + .toList())); + assertTrue(script.struct().members().get(0).isInput()); + assertTrue(script.struct().members().get(2).isOutput()); + } + + /** It runs, and what it assigned comes back as its outputs. */ + @Test + public void testRun() { + final Map outputs = run(greeting(), Map.of( // + "name", "ada", "shout", false)); + + assertEquals("hello ada", outputs.get("greeting")); + } + + /** Supplied values are converted to the declared types, as for a command. */ + @Test + public void testInputsAreConverted() { + // NB: strings for everything, which is all a command line ever has. + final Map outputs = run(greeting(), Map.of( // + "name", "ada", "shout", "true")); + + assertEquals("HELLO ADA", outputs.get("greeting")); + } + + /** + * A script's callback runs, which SciJava Common could never do. + *

+ * It resolved {@code callback = "foo"} by reflecting a Java method, leaving + * a script nothing to name. Here the name is resolved by whatever kind of + * code the executable is - and for a script, that means a function in the + * script. + *

+ */ + @Test + public void testScriptCallback() { + final ScriptExecutable script = scripts.of("callbacks.groovy", "" + // + "#@ double(callback = \"celsiusChanged\") celsius\n" + // + "#@ double fahrenheit\n" + // + "def celsiusChanged() {\n" + // + " fahrenheit = celsius * 9 / 5 + 32\n" + // + "}\n", "groovy"); + + final ExecutableInstance instance = script.create(); + instance.parameters().member("celsius").set(100.0); + + final Optional callback = instance.behavior("celsiusChanged"); + assertTrue(callback.isPresent(), "the script's own function"); + callback.get().invoke(); + + assertEquals(212.0, instance.parameters().member("fahrenheit").get()); + } + + /** A behavior the script does not define is absent, not an error. */ + @Test + public void testMissingBehavior() { + final ExecutableInstance instance = greeting().create(); + assertTrue(instance.behavior("noSuchFunction").isPresent(), + "an Invocable engine offers any name..."); + assertThrowsScriptException(() -> instance.behavior("noSuchFunction").get() + .invoke()); + } + + /** A script reaches the same processors, and can be declined the same way. */ + @Test + public void testPreprocessorsApplyToScripts() throws Exception { + final Preprocessor filler = execution -> execution.instance().member("name") + .set("grace"); + final Runner runner = Runner.of(List.of(filler), List.of()); + + final Future future = runner.run(greeting(), Map.of( // + "shout", false)); + final ExecutionResult result = future.get(); + + assertFalse(result.isDeclined()); + assertEquals("hello grace", result.outputs().get("greeting")); + } + + /** A failing script says what went wrong, rather than failing silently. */ + @Test + public void testFailingScript() { + final ScriptExecutable script = scripts.of("bad.groovy", // + "#@output String result\nthis is not groovy\n", "groovy"); + + assertThrowsScriptException(() -> run(script, Map.of())); + } + + // -- Helper methods -- + + private ScriptExecutable greeting() { + return scripts.of("greeting.groovy", "" + // + "#@ String name\n" + // + "#@ boolean shout\n" + // + "#@output String greeting\n" + // + "greeting = \"hello \" + name\n" + // + "if (shout) greeting = greeting.toUpperCase()\n", "groovy"); + } + + /** Runs a script the way anything else is run: through a Runner. */ + private static Map run(final ScriptExecutable script, + final Map inputs) + { + try { + final ExecutionResult result = Runner.of(List.of(), List.of()) // + .run(script, inputs).get(); + assertFalse(result.isDeclined(), "should not have been declined"); + return result.outputs(); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exc); + } + catch (final java.util.concurrent.ExecutionException exc) { + // NB: unwrap, so a failing script reports as a script failure. + if (exc.getCause() instanceof RuntimeException) { + throw (RuntimeException) exc.getCause(); + } + throw new IllegalStateException(exc.getCause()); + } + } + + private static void assertThrowsScriptException(final Runnable work) { + try { + work.run(); + } + catch (final ScriptException exc) { + return; + } + catch (final RuntimeException exc) { + if (exc.getCause() instanceof ScriptException) return; + throw exc; + } + throw new AssertionError("Expected a ScriptException"); + } +} diff --git a/script3/src/test/java/org/scijava/script3/ScriptFinderTest.java b/script3/src/test/java/org/scijava/script3/ScriptFinderTest.java new file mode 100644 index 000000000..9fc507b2a --- /dev/null +++ b/script3/src/test/java/org/scijava/script3/ScriptFinderTest.java @@ -0,0 +1,130 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +/** + * Tests finding scripts, and what they say about themselves. + * + * @author Curtis Rueden + */ +public class ScriptFinderTest { + + /** A script says where it belongs; a directory says it for the rest. */ + @Test + public void testMetadata() throws URISyntaxException { + final List found = new ScriptFinder().find(scripts()); + assertEquals(2, found.size(), found.toString()); + + final Map declared = metadata(found, "Say_Hello.groovy"); + assertEquals("Help>Say Hello From A Script", declared.get("menu")); + assertEquals("^H", declared.get("accelerator")); + assertEquals("50", declared.get("weight")); + + // NB: this one declares nothing, so where it sits is what it means - + // underscores read as spaces, extension dropped. + final Map implied = metadata(found, "Blur_It.groovy"); + assertEquals("Process>Filters>Blur It", implied.get("menu")); + } + + /** What was found runs, with its declared defaults. */ + @Test + public void testFoundScriptRuns() throws Exception { + final ScriptExecutable hello = script(new ScriptFinder().find(scripts()), + "Say_Hello.groovy"); + + final var instance = hello.create(); + instance.run(); + + assertEquals("hello world", instance.parameters().member("greeting") + .get()); + } + + /** A script may name its own language, which settles a shared extension. */ + @Test + public void testDeclaredLanguage() { + final Scripts scripts = Scripts.get(); + + // NB: were Jython installed, this .py would be read as Groovy anyway. + final ScriptExecutable script = scripts.of("mystery.py", // + "#@script(language = \"groovy\")\n#@output String x\nx = \"hi\"\n"); + assertEquals("Groovy", script.language().name()); + + final ScriptExecutable shebanged = scripts.of("mystery.py", // + "#!/usr/bin/env groovy\n#@output String x\nx = \"hi\"\n"); + assertEquals("Groovy", shebanged.language().name()); + } + + /** A file no language claims is skipped, not fatal. */ + @Test + public void testUnknownExtensionsAreSkipped() { + final Optional none = Scripts.get().language("xyzzy"); + assertTrue(none.isEmpty(), "no language should claim .xyzzy"); + // NB: the scripts directory holds only .groovy files here, but a real + // one holds text files, images and languages this install lacks. + assertEquals(2, new ScriptFinder().find(scripts()).size()); + } + + // -- Helper methods -- + + private static Path scripts() { + try { + return Paths.get(ScriptFinderTest.class.getResource("/scripts").toURI()); + } + catch (final URISyntaxException exc) { + throw new IllegalStateException(exc); + } + } + + private static Map metadata( + final List found, final String name) + { + return found.stream().filter(f -> f.script().name().equals(name)) // + .findFirst().orElseThrow().metadata(); + } + + private static ScriptExecutable script(final List found, + final String name) + { + return found.stream().filter(f -> f.script().name().equals(name)) // + .findFirst().orElseThrow().script(); + } +} diff --git a/script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java b/script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java new file mode 100644 index 000000000..ee2150318 --- /dev/null +++ b/script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java @@ -0,0 +1,131 @@ +/* + * #%L + * Running scripts as things that declare their inputs and outputs. + * %% + * Copyright (C) 2026 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.script3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.scijava.struct.ItemIO; + +/** + * Tests reading what a script declares about itself. + * + * @author Curtis Rueden + */ +public class ScriptHeaderTest { + + @Test + public void testPlainParameters() { + final ScriptHeader header = ScriptHeader.parse("" + // + "#@ String name\n" + // + "#@ int count\n" + // + "#@output String greeting\n" + // + "println(\"hi\")\n"); + + final List params = header.parameters(); + assertEquals(3, params.size()); + assertEquals("name", params.get(0).name()); + assertEquals(String.class, params.get(0).type()); + assertEquals(ItemIO.INPUT, params.get(0).io()); + assertEquals(int.class, params.get(1).type()); + assertEquals(ItemIO.OUTPUT, params.get(2).io()); + } + + /** The attributes are the ones a Java {@code @Parameter} carries. */ + @Test + public void testAttributes() { + final ScriptHeader header = ScriptHeader.parse( // + "#@ String(label = \"Your name\", value = \"ada\", " + // + "choices = \"ada,grace\") name\n"); + + final Map attrs = header.parameters().get(0).attrs(); + assertEquals("Your name", attrs.get("label")); + assertEquals("ada", attrs.get("value")); + // NB: the comma inside the quotes is part of the value, not a separator. + assertEquals("ada,grace", attrs.get("choices")); + } + + /** A type may be left out, and a direction stated instead. */ + @Test + public void testTypeAndDirectionVariants() { + final ScriptHeader header = ScriptHeader.parse("" + // + "#@output result\n" + // + "#@both double weight\n" + // + "#@input(persist = false) boolean verbose\n"); + + assertEquals(Object.class, header.parameters().get(0).type()); + assertEquals(ItemIO.OUTPUT, header.parameters().get(0).io()); + assertEquals(ItemIO.MUTABLE, header.parameters().get(1).io()); + assertEquals(ItemIO.INPUT, header.parameters().get(2).io()); + assertEquals("false", header.parameters().get(2).attrs().get("persist")); + } + + /** Unqualified type names resolve the way a script writer expects. */ + @Test + public void testTypeNames() { + final ScriptHeader header = ScriptHeader.parse("" + // + "#@ File input\n" + // + "#@ BigDecimal exact\n" + // + "#@ java.util.List items\n"); + + assertEquals(File.class, header.parameters().get(0).type()); + assertEquals(java.math.BigDecimal.class, header.parameters().get(1) + .type()); + assertEquals(List.class, header.parameters().get(2).type()); + } + + /** A script may say things about itself, not only about its parameters. */ + @Test + public void testDirectives() { + final ScriptHeader header = ScriptHeader.parse( // + "#@script(menu = \"Plugins>Gauss\", label = \"Gaussian Blur\")\n"); + + assertEquals("Plugins>Gauss", header.directives().get("menu")); + assertEquals("Gaussian Blur", header.directives().get("label")); + assertTrue(header.parameters().isEmpty()); + } + + /** Lines that are not declarations are left alone. */ + @Test + public void testOrdinaryLines() { + final ScriptHeader header = ScriptHeader.parse("" + // + "# an ordinary comment\n" + // + "x = 5 # @ not a parameter\n" + // + "#@ String name\n"); + + assertEquals(1, header.parameters().size()); + assertEquals("name", header.parameters().get(0).name()); + } +} diff --git a/script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy @@ -0,0 +1,3 @@ +#@ double(label = "Radius", min = 0, max = 20, style = "slider,ticks:4") sigma +#@output String result +result = "Blurred with sigma " + sigma diff --git a/script3/src/test/resources/scripts/Say_Hello.groovy b/script3/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/script3/src/test/resources/scripts/Say_Hello.groovy @@ -0,0 +1,4 @@ +#@script(menu = "Help>Say Hello From A Script", accelerator = "^H", weight = 50) +#@ String(value = "world") name +#@output String greeting +greeting = "hello " + name diff --git a/settings/pom.xml b/settings/pom.xml new file mode 100644 index 000000000..5dc980ac7 --- /dev/null +++ b/settings/pom.xml @@ -0,0 +1,182 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-settings + + 1.0.0-SNAPSHOT + + SciJava Settings + Settings a user can read and edit, in one TOML file. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.settings + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-convert3 + ${scijava-convert3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/settings/src/main/java/module-info.java b/settings/src/main/java/module-info.java new file mode 100644 index 000000000..98d796b42 --- /dev/null +++ b/settings/src/main/java/module-info.java @@ -0,0 +1,41 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.settings { + + exports org.scijava.settings; + + requires transitive org.scijava.convert3; + requires transitive org.scijava.execute; + requires org.scijava.common3; + requires org.scijava.priority; + requires org.scijava.struct; + requires org.slf4j; + +} diff --git a/settings/src/main/java/org/scijava/settings/LoadInputs.java b/settings/src/main/java/org/scijava/settings/LoadInputs.java new file mode 100644 index 000000000..f61f85b48 --- /dev/null +++ b/settings/src/main/java/org/scijava/settings/LoadInputs.java @@ -0,0 +1,82 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import java.util.Optional; + +import org.scijava.execute.Execution; +import org.scijava.execute.Preprocessor; +import org.scijava.priority.Priority; +import org.scijava.struct.MemberInstance; + +/** + * Fills in what the user chose last time. + *

+ * It runs early - before the harvester - so that a dialog opens showing the + * values from the previous run, which is what makes a dialog feel like it + * remembers you. A value the caller supplied is left alone: an input given on + * a command line, or by the code doing the running, is that run's business. + *

+ * + * @author Curtis Rueden + */ +public class LoadInputs implements Preprocessor { + + private final Settings settings; + + public LoadInputs(final Settings settings) { + this.settings = settings; + } + + @Override + public void process(final Execution execution) { + final String table = RememberedInputs.table(execution.executable() + .executable()); + for (final MemberInstance member : RememberedInputs.remembered(execution + .instance())) + { + // NB: only where the caller supplied nothing. Asking the parameter + // whether it is empty would not do: a primitive is never empty, so a + // `double` would never be filled in, and overwriting regardless would + // make a remembered value beat an explicit one. + if (execution.isSupplied(member.member().key())) continue; + final String key = RememberedInputs.key(member.member()); + final Optional value = settings.get(table, key, member.member() + .type()); + value.ifPresent(member::set); + } + } + + @Override + public double priority() { + // NB: before the harvester, so that the dialog shows what was remembered. + return Priority.HIGH; + } +} diff --git a/settings/src/main/java/org/scijava/settings/RememberedInputs.java b/settings/src/main/java/org/scijava/settings/RememberedInputs.java new file mode 100644 index 000000000..c2d2879cb --- /dev/null +++ b/settings/src/main/java/org/scijava/settings/RememberedInputs.java @@ -0,0 +1,104 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import java.util.Optional; + +import org.scijava.execute.ParameterMember; +import org.scijava.struct.Member; +import org.scijava.struct.MemberInstance; +import org.scijava.struct.StructInstance; + +/** + * What a command remembers between runs, and what it does not. + *

+ * The shared part of {@link LoadInputs} and {@link SaveInputs}: which table a + * command's values live in, and which of its parameters are worth keeping. + *

+ * + * @author Curtis Rueden + */ +final class RememberedInputs { + + private RememberedInputs() { + // prevent instantiation of utility class + } + + /** + * Gets the table a command's values live in: its identifier. + *

+ * NB: the class name for a command, the path for a script - what + * {@code Executable.name()} says, and nothing about which artifact it came + * in. A class moving between components would otherwise lose a user's + * settings, which is a refactor nobody should be punished for. + *

+ */ + static String table(final org.scijava.execute.Executable executable) { + return executable.name(); + } + + /** Gets the key one parameter is remembered under. */ + static String key(final Member member) { + return attr(member, ParameterMember.PERSIST_KEY).orElseGet(member::key); + } + + /** + * Gets whether a parameter is worth remembering. + *

+ * Inputs are, unless they say {@code persist = false}. Outputs are not, and + * neither is anything the caller supplied outright - a value given on a + * command line or by another command is that run's business, not the user's + * remembered choice. + *

+ */ + static boolean remembers(final MemberInstance instance) { + final Member member = instance.member(); + if (!member.isInput()) return false; + return !attr(member, ParameterMember.PERSIST).map("false"::equals).orElse(false); + } + + /** Gets every parameter of a run that is worth remembering. */ + static Iterable> remembered( + final StructInstance instance) + { + final java.util.List> members = new java.util.ArrayList<>(); + for (final MemberInstance member : instance.members()) { + if (remembers(member)) members.add(member); + } + return members; + } + + private static Optional attr(final Member member, + final String key) + { + if (!(member instanceof ParameterMember)) return Optional.empty(); + return ((ParameterMember) member).attr(key); + } +} diff --git a/settings/src/main/java/org/scijava/settings/SaveInputs.java b/settings/src/main/java/org/scijava/settings/SaveInputs.java new file mode 100644 index 000000000..96b1a7088 --- /dev/null +++ b/settings/src/main/java/org/scijava/settings/SaveInputs.java @@ -0,0 +1,80 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import org.scijava.execute.Execution; +import org.scijava.execute.Preprocessor; +import org.scijava.priority.Priority; +import org.scijava.struct.MemberInstance; + +/** + * Remembers what the user just chose. + *

+ * NB: a preprocessor, and deliberately. Saving after the run would + * lose the values whenever the run threw - which is exactly when a user is + * about to try again, and exactly when retyping everything is most + * infuriating. It runs last among preprocessors, after the harvester has + * collected whatever the user typed and after a declined run has stopped the + * chain, so what it stores is a complete set of values somebody meant. + *

+ * + * @author Curtis Rueden + */ +public class SaveInputs implements Preprocessor { + + private final Settings settings; + + public SaveInputs(final Settings settings) { + this.settings = settings; + } + + @Override + public void process(final Execution execution) { + final String table = RememberedInputs.table(execution.executable() + .executable()); + boolean stored = false; + for (final MemberInstance member : RememberedInputs.remembered(execution + .instance())) + { + final Object value = member.get(); + if (value == null) continue; + stored |= settings.set(table, RememberedInputs.key(member.member()), + value); + } + if (stored) settings.save(); + } + + @Override + public double priority() { + // NB: after the harvester, which is VERY_LOW, so that what the user typed + // is what gets remembered. + return Priority.EXTREMELY_LOW; + } +} diff --git a/settings/src/main/java/org/scijava/settings/Settings.java b/settings/src/main/java/org/scijava/settings/Settings.java new file mode 100644 index 000000000..cd8f78bdc --- /dev/null +++ b/settings/src/main/java/org/scijava/settings/Settings.java @@ -0,0 +1,354 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Array; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import org.scijava.convert3.Converters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Settings a user can read and edit, kept in one TOML file. + *

+ * It is an object, not a singleton and not a service: settings belong to an + * application, and two applications sharing a JVM want two of them. Ask a + * container for one, or make one: + *

+ * + *
+ * Settings settings = Settings.forApp("fiji");   // ~/.config/fiji/settings.toml
+ * Settings settings = Settings.of(somePath);     // exactly there
+ * Settings settings = Settings.inMemory();       // for a test
+ * 
+ *

+ * One path on every platform. {@code ~/.config/} on + * Linux, macOS and Windows alike, honoring {@code XDG_CONFIG_HOME} where it is + * set. This is deliberate and it is not what the platforms would each prefer: + * the point of leaving {@code java.util.prefs} behind was that settings should + * be a file a person can open, copy between machines, put in a repository and + * name in a forum post - and one answer to "where is it?" is worth more to + * that than matching each platform's convention. Appose made the same choice, + * and an override is a system property away for anyone who disagrees. + *

+ * + * @author Curtis Rueden + */ +public class Settings { + + private static final Logger log = LoggerFactory.getLogger(Settings.class); + + /** System property naming the settings file outright. */ + public static final String FILE_PROPERTY = "scijava.settings.file"; + + /** System property naming the directory the settings file sits in. */ + public static final String DIR_PROPERTY = "scijava.config.dir"; + + /** System property naming the application, as Jaunch sets it. */ + public static final String APP_PROPERTY = "scijava.app.name"; + + private final Path file; + private final Toml toml; + + private Settings(final Path file, final Toml toml) { + this.file = file; + this.toml = toml; + } + + /** Reads the settings in the given file, which need not exist yet. */ + public static Settings of(final Path file) { + return new Settings(file, read(file)); + } + + /** + * Reads the settings of the named application. + * + * @param appName the application's name, as it should appear in the path + * @return its settings + */ + public static Settings forApp(final String appName) { + return of(fileFor(appName)); + } + + /** + * Reads the settings of the application this JVM is running, as named by + * the {@code scijava.app.name} property. + */ + public static Settings forApp() { + return forApp(System.getProperty(APP_PROPERTY, "scijava")); + } + + /** Creates settings with nowhere to save to, for tests and scratch use. */ + public static Settings inMemory() { + return new Settings(null, Toml.empty()); + } + + /** Works out where the named application's settings live. */ + public static Path fileFor(final String appName) { + final String explicitFile = System.getProperty(FILE_PROPERTY); + if (explicitFile != null && !explicitFile.isEmpty()) { + return Paths.get(explicitFile); + } + final String explicitDir = System.getProperty(DIR_PROPERTY); + if (explicitDir != null && !explicitDir.isEmpty()) { + return Paths.get(explicitDir).resolve("settings.toml"); + } + final String xdg = System.getenv("XDG_CONFIG_HOME"); + final Path config = xdg != null && !xdg.isEmpty() ? Paths.get(xdg) // + : Paths.get(System.getProperty("user.home"), ".config"); + return config.resolve(appName).resolve("settings.toml"); + } + + /** Gets the file these settings live in, if they live anywhere. */ + public Optional file() { + return Optional.ofNullable(file); + } + + /** Gets the TOML itself, for whatever this class does not do. */ + public Toml toml() { + return toml; + } + + /** Gets a value as it is stored. */ + public Object get(final String table, final String key) { + return toml.get(table, key); + } + + /** + * Gets a value as the type the caller wants. + *

+ * The conversion is {@code scijava-convert3}'s, the same one a script + * header, a command line and a text field use - so a {@code File} comes back + * a {@code File}, an enum comes back an enum, and a list of paths comes back + * a {@code List} if that is what was asked for. + *

+ * + * @param the type wanted + * @param table the table, which for a command is its identifier + * @param key the key, which for a parameter is its name + * @param type the type wanted + * @return the value, or empty if there is none or it cannot be that type + */ + public Optional get(final String table, final String key, + final Class type) + { + @SuppressWarnings("unchecked") + final Optional value = (Optional) get(table, key, (Type) type); + return value; + } + + /** Gets a value as the given type, generics included. */ + public Optional get(final String table, final String key, + final Type type) + { + final Object stored = toml.get(table, key); + if (stored == null) return Optional.empty(); + final Optional converted = Converters.get().tryConvert(stored, + type); + if (converted.isEmpty()) { + log.debug("Cannot read {}.{} as {}: it is stored as {}", table, key, type, + stored.getClass().getSimpleName()); + } + return converted; + } + + /** + * Stores a value, in whichever TOML type suits it. + *

+ * TOML has a small fixed set of types, so the mapping is a table: text and + * characters and enums and files become strings, whole numbers become + * integers, fractional ones floats, the date-and-time types map across + * directly, and a collection or array becomes an array of whatever its + * elements map to. Anything else is asked to describe itself as a string, + * which round-trips for every type that has a converter back - and is + * skipped, with a word in the log, for one that does not. + *

+ * + * @param table the table, which for a command is its identifier + * @param key the key, which for a parameter is its name + * @param value what to store + * @return whether it could be stored + */ + public boolean set(final String table, final String key, + final Object value) + { + if (value == null) { + toml.remove(table, key); + return true; + } + final Object stored = toTomlValue(value); + if (stored == null) { + log.debug("Not storing {}.{}: nothing in TOML holds a {}", table, key, + value.getClass().getName()); + return false; + } + toml.set(table, key, stored); + return true; + } + + /** Forgets one value. */ + public void remove(final String table, final String key) { + toml.remove(table, key); + } + + /** Forgets everything one command remembered. */ + public void removeTable(final String table) { + toml.removeTable(table); + } + + /** + * Writes the settings out. + *

+ * NB: through a temporary file and a move, so that a crash midway leaves the + * old settings rather than half of the new ones. + *

+ */ + public void save() { + if (file == null) return; // NB: nowhere to save to, which is allowed + try { + final Path directory = file.toAbsolutePath().getParent(); + if (directory != null) Files.createDirectories(directory); + final Path temporary = file.resolveSibling(file.getFileName() + ".new"); + Files.write(temporary, toml.write().getBytes(StandardCharsets.UTF_8)); + try { + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } + catch (final java.nio.file.AtomicMoveNotSupportedException exc) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + } + catch (final IOException exc) { + throw new UncheckedIOException("Cannot save settings: " + file, exc); + } + } + + @Override + public String toString() { + return file == null ? "settings (in memory)" : "settings (" + file + ")"; + } + + // -- Helper methods -- + + private static Toml read(final Path file) { + if (file == null || !Files.isReadable(file)) return Toml.empty(); + try { + return Toml.parse(new String(Files.readAllBytes(file), + StandardCharsets.UTF_8)); + } + catch (final IOException exc) { + throw new UncheckedIOException("Cannot read settings: " + file, exc); + } + } + + /** + * Maps a value onto one of TOML's types. + * + * @return the TOML value, or null if nothing there can hold it + */ + private static Object toTomlValue(final Object value) { + // the types TOML has + if (value instanceof String || value instanceof Boolean || // + value instanceof LocalDate || value instanceof LocalTime || // + value instanceof LocalDateTime || value instanceof OffsetDateTime) + { + return value; + } + // whole numbers, and fractional ones + if (value instanceof Byte || value instanceof Short || // + value instanceof Integer || value instanceof Long) + { + return ((Number) value).longValue(); + } + if (value instanceof Float || value instanceof Double) { + return ((Number) value).doubleValue(); + } + // NB: as strings, because TOML's integers are 64-bit and its floats are + // doubles -- storing these as numbers would quietly lose what the type + // was chosen for. + if (value instanceof BigInteger || value instanceof BigDecimal) { + return value.toString(); + } + if (value instanceof Character || value instanceof Enum) { + return value.toString(); + } + // NB: an old-style date is the one type that needs a decision: it is an + // instant, so it keeps its offset rather than pretending to be local. + if (value instanceof Date) { + return OffsetDateTime.ofInstant(((Date) value).toInstant(), ZoneId + .systemDefault()); + } + // containers become arrays, element by element + if (value instanceof Collection) { + return toTomlArray(((Collection) value).toArray()); + } + if (value.getClass().isArray()) { + final int length = Array.getLength(value); + final Object[] elements = new Object[length]; + for (int i = 0; i < length; i++) + elements[i] = Array.get(value, i); + return toTomlArray(elements); + } + // NB: last resort, and the reason a File or a URI needs no special case: + // anything that can describe itself as a string does, and comes back + // through the converter that reads that string. + return Converters.get().tryConvert(value, String.class).orElse(null); + } + + private static Object toTomlArray(final Object[] elements) { + final List list = new ArrayList<>(elements.length); + for (final Object element : elements) { + final Object stored = toTomlValue(element); + if (stored == null) return null; // NB: all of it, or none of it + list.add(stored); + } + return list; + } +} diff --git a/settings/src/main/java/org/scijava/settings/Toml.java b/settings/src/main/java/org/scijava/settings/Toml.java new file mode 100644 index 000000000..2ab96e8c8 --- /dev/null +++ b/settings/src/main/java/org/scijava/settings/Toml.java @@ -0,0 +1,645 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A TOML document: tables of values, and the comments around them. + *

+ * Enough TOML for settings, and no more: tables, bare and quoted keys, + * strings (basic, literal, and both multiline forms), integers in decimal, + * hex, octal and binary, floats including {@code inf} and {@code nan}, + * booleans, all four date-and-time types, and arrays of any of those. Inline + * tables and arrays of tables are refused by line number rather than + * misunderstood. + *

+ *

+ * NB: comments are kept. This file is one a person edits, and + * a store that swallowed the {@code # why this is 0.7} somebody left beside a + * value is a store they would stop editing. A comment belongs to whatever + * follows it, and comes back out above it. + *

+ *

+ * NB: the API is deliberately small - parse, get, set, write - so that a real + * TOML library can take its place if settings ever outgrow this. + *

+ * + * @author Curtis Rueden + */ +public class Toml { + + /** The name of the table holding values written before any header. */ + public static final String ROOT = ""; + + /** One value, and the comments written above it. */ + private static class Entry { + + final List comments; + Object value; + + Entry(final List comments, final Object value) { + this.comments = new ArrayList<>(comments); + this.value = value; + } + } + + /** One table, in the order its keys were written. */ + private static class Table { + + final List comments = new ArrayList<>(); + final Map entries = new LinkedHashMap<>(); + } + + private final Map tables = new LinkedHashMap<>(); + private final List trailing = new ArrayList<>(); + + /** Creates an empty document. */ + public static Toml empty() { + return new Toml(); + } + + /** + * Reads a document. + * + * @param text the TOML + * @return the document + * @throws TomlException if it cannot be read + */ + public static Toml parse(final String text) { + return new Parser(text).parse(); + } + + /** Gets the table names, in the order they appear. */ + public List tables() { + return List.copyOf(tables.keySet()); + } + + /** Gets whether the document has the given table. */ + public boolean hasTable(final String table) { + return tables.containsKey(table); + } + + /** Gets a table's values, in the order they appear. */ + public Map table(final String table) { + final Table t = tables.get(table); + if (t == null) return Map.of(); + final Map values = new LinkedHashMap<>(); + t.entries.forEach((key, entry) -> values.put(key, entry.value)); + return values; + } + + /** Gets one value, or null if there is none. */ + public Object get(final String table, final String key) { + final Table t = tables.get(table); + if (t == null) return null; + final Entry entry = t.entries.get(key); + return entry == null ? null : entry.value; + } + + /** + * Sets one value, keeping whatever comments were written around it. + * + * @param table the table, {@link #ROOT} for the values above any header + * @param key the key + * @param value a string, number, boolean, date, time, or list of those + */ + public void set(final String table, final String key, final Object value) { + check(value); + final Table t = tables.computeIfAbsent(table, name -> new Table()); + final Entry entry = t.entries.get(key); + if (entry == null) t.entries.put(key, new Entry(List.of(), value)); + else entry.value = value; + } + + /** Removes one value. */ + public void remove(final String table, final String key) { + final Table t = tables.get(table); + if (t != null) t.entries.remove(key); + } + + /** Removes a table and everything in it. */ + public void removeTable(final String table) { + tables.remove(table); + } + + /** Writes the document out, comments and order intact. */ + public String write() { + final StringBuilder sb = new StringBuilder(); + tables.forEach((name, table) -> { + if (table.entries.isEmpty() && !name.equals(ROOT)) return; + table.comments.forEach(c -> sb.append(c).append('\n')); + if (!name.equals(ROOT)) { + sb.append('[').append(writeKey(name)).append("]\n"); + } + table.entries.forEach((key, entry) -> { + entry.comments.forEach(c -> sb.append(c).append('\n')); + sb.append(writeKey(key)).append(" = ").append(writeValue(entry.value)) + .append('\n'); + }); + sb.append('\n'); + }); + trailing.forEach(c -> sb.append(c).append('\n')); + return sb.toString(); + } + + @Override + public String toString() { + return write(); + } + + // -- Helper methods -- + + /** Refuses a value TOML cannot hold, rather than writing nonsense. */ + private static void check(final Object value) { + if (value == null) throw new IllegalArgumentException("No value"); + if (value instanceof String || value instanceof Boolean || // + value instanceof Long || value instanceof Integer || // + value instanceof Double || value instanceof Float || // + value instanceof LocalDate || value instanceof LocalTime || // + value instanceof LocalDateTime || value instanceof OffsetDateTime) + { + return; + } + if (value instanceof List) { + ((List) value).forEach(Toml::check); + return; + } + throw new IllegalArgumentException("TOML cannot hold a " + value.getClass() + .getName()); + } + + /** Writes a key, quoting it where TOML would not take it bare. */ + private static String writeKey(final String key) { + if (key.isEmpty()) return "\"\""; + for (int i = 0; i < key.length(); i++) { + final char c = key.charAt(i); + final boolean bare = Character.isLetterOrDigit(c) && c < 128 || // + c == '_' || c == '-' || c == '.'; + if (!bare) return quote(key); + } + return key; + } + + private static String writeValue(final Object value) { + if (value instanceof String) { + final String s = (String) value; + // NB: a value with newlines in it is written the way TOML writes one, + // so that a text area's contents stay readable in the file. + if (s.indexOf('\n') >= 0) { + return "\"\"\"\n" + s.replace("\\", "\\\\").replace("\"\"\"", + "\\\"\\\"\\\"") + "\"\"\""; + } + return quote(s); + } + if (value instanceof Double || value instanceof Float) { + final double d = ((Number) value).doubleValue(); + if (Double.isNaN(d)) return "nan"; + if (d == Double.POSITIVE_INFINITY) return "inf"; + if (d == Double.NEGATIVE_INFINITY) return "-inf"; + return String.valueOf(d); + } + if (value instanceof List) { + final StringBuilder sb = new StringBuilder("["); + final List list = (List) value; + for (int i = 0; i < list.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(writeValue(list.get(i))); + } + return sb.append(']').toString(); + } + // NB: numbers, booleans and the date-and-time types all write themselves + // in exactly the form TOML wants. + return String.valueOf(value); + } + + private static String quote(final String s) { + final StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\t': + sb.append("\\t"); + break; + case '\n': + sb.append("\\n"); + break; + case '\f': + sb.append("\\f"); + break; + case '\r': + sb.append("\\r"); + break; + default: + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + return sb.append('"').toString(); + } + + /** Reads the text into tables, keeping the comments. */ + private static class Parser { + + private final String s; + private int i; + private int line = 1; + + private Toml toml = new Toml(); + private Table current; + private final List pending = new ArrayList<>(); + + Parser(final String text) { + this.s = text; + current = toml.tables.computeIfAbsent(ROOT, name -> new Table()); + } + + Toml parse() { + while (true) { + skipSpace(); + if (eof()) break; + final char c = peek(); + if (c == '\n') { + next(); + line++; + continue; + } + if (c == '#') { + pending.add(readComment()); + continue; + } + if (c == '[') { + readTableHeader(); + continue; + } + readEntry(); + } + // NB: comments with nothing after them belong to the end of the file. + toml.trailing.addAll(pending); + pending.clear(); + return toml; + } + + private void readTableHeader() { + next(); // [ + if (!eof() && peek() == '[') { + throw new TomlException("arrays of tables are not supported here", + line); + } + skipSpace(); + final String name = readKey(); + skipSpace(); + if (eof() || next() != ']') { + throw new TomlException("expected ']' after a table name", line); + } + final Table table = toml.tables.computeIfAbsent(name, + n -> new Table()); + table.comments.addAll(pending); + pending.clear(); + current = table; + endOfLine(); + } + + private void readEntry() { + final String key = readKey(); + skipSpace(); + if (eof() || next() != '=') { + throw new TomlException("expected '=' after the key '" + key + "'", + line); + } + skipSpace(); + final Object value = readValue(); + current.entries.put(key, new Entry(pending, value)); + pending.clear(); + endOfLine(); + } + + /** Reads a key: bare, quoted, or dotted; dotted keys stay one name. */ + private String readKey() { + final StringBuilder sb = new StringBuilder(); + while (true) { + skipSpace(); + if (eof()) throw new TomlException("expected a key", line); + final char c = peek(); + if (c == '"' || c == '\'') sb.append(readString()); + else { + final int start = i; + while (!eof() && isBareKeyChar(peek())) + next(); + if (i == start) { + throw new TomlException("expected a key, found '" + peek() + "'", + line); + } + sb.append(s, start, i); + } + skipSpace(); + if (!eof() && peek() == '.') { + next(); + sb.append('.'); + continue; + } + return sb.toString(); + } + } + + private Object readValue() { + if (eof()) throw new TomlException("expected a value", line); + final char c = peek(); + if (c == '"' || c == '\'') return readString(); + if (c == '[') return readArray(); + if (c == '{') { + throw new TomlException("inline tables are not supported here", line); + } + return readBareValue(); + } + + private List readArray() { + next(); // [ + final List list = new ArrayList<>(); + while (true) { + skipArraySpace(); + if (eof()) throw new TomlException("unclosed array", line); + if (peek() == ']') { + next(); + return list; + } + list.add(readValue()); + skipArraySpace(); + if (eof()) throw new TomlException("unclosed array", line); + if (peek() == ',') { + next(); + continue; + } + if (peek() != ']') { + throw new TomlException("expected ',' or ']' in an array", line); + } + } + } + + /** Reads a bare value: a number, a boolean, or a date. */ + private Object readBareValue() { + final int start = i; + while (!eof() && "\n,]#".indexOf(peek()) < 0) + next(); + final String text = s.substring(start, i).trim(); + if (text.isEmpty()) throw new TomlException("expected a value", line); + if (text.equals("true")) return Boolean.TRUE; + if (text.equals("false")) return Boolean.FALSE; + + final Object moment = readMoment(text); + if (moment != null) return moment; + + try { + return readNumber(text); + } + catch (final NumberFormatException exc) { + throw new TomlException("cannot read the value '" + text + "'", line); + } + } + + /** Reads one of TOML's four date-and-time types, or nothing. */ + private Object readMoment(final String text) { + try { + if (text.matches("\\d{4}-\\d{2}-\\d{2}")) return LocalDate.parse(text); + if (text.matches("\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?")) { + return LocalTime.parse(text); + } + final String normalized = text.replaceFirst("^(\\d{4}-\\d{2}-\\d{2}) ", + "$1T"); + if (normalized.matches( + "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2}(\\.\\d+)?)?")) + { + return LocalDateTime.parse(normalized); + } + if (normalized.matches(".*(Z|[+-]\\d{2}:\\d{2})$") && normalized + .contains("T")) + { + return OffsetDateTime.parse(normalized); + } + } + catch (final java.time.format.DateTimeParseException exc) { + throw new TomlException("cannot read the date '" + text + "'", line); + } + return null; + } + + private Object readNumber(final String text) { + final String cleaned = text.replace("_", ""); + if (cleaned.equals("inf") || cleaned.equals("+inf")) { + return Double.POSITIVE_INFINITY; + } + if (cleaned.equals("-inf")) return Double.NEGATIVE_INFINITY; + if (cleaned.equals("nan") || cleaned.equals("+nan") || cleaned.equals( + "-nan")) return Double.NaN; + if (cleaned.startsWith("0x")) { + return Long.parseLong(cleaned.substring(2), 16); + } + if (cleaned.startsWith("0o")) { + return Long.parseLong(cleaned.substring(2), 8); + } + if (cleaned.startsWith("0b")) { + return Long.parseLong(cleaned.substring(2), 2); + } + if (cleaned.indexOf('.') >= 0 || cleaned.indexOf('e') >= 0 || cleaned + .indexOf('E') >= 0) + { + return Double.valueOf(cleaned); + } + return Long.valueOf(cleaned); + } + + /** Reads a string in any of TOML's four spellings. */ + private String readString() { + final char quote = next(); + final boolean multiline = !eof() && peek() == quote && i + 1 < s + .length() && s.charAt(i + 1) == quote; + if (multiline) { + next(); + next(); + // NB: a newline straight after the opening quotes is not content. + if (!eof() && peek() == '\n') { + next(); + line++; + } + } + final StringBuilder sb = new StringBuilder(); + while (true) { + if (eof()) throw new TomlException("unclosed string", line); + final char c = next(); + if (c == '\n') line++; + if (c == quote) { + if (!multiline) return sb.toString(); + if (i + 1 < s.length() && s.charAt(i) == quote && s.charAt(i + 1) == + quote) + { + next(); + next(); + return sb.toString(); + } + sb.append(c); + continue; + } + if (c == '\\' && quote == '"') { + sb.append(readEscape(multiline)); + continue; + } + sb.append(c); + } + } + + private String readEscape(final boolean multiline) { + if (eof()) throw new TomlException("unfinished escape", line); + final char c = next(); + switch (c) { + case '"': + return "\""; + case '\\': + return "\\"; + case 'b': + return "\b"; + case 't': + return "\t"; + case 'n': + return "\n"; + case 'f': + return "\f"; + case 'r': + return "\r"; + case 'u': + return readCodePoint(4); + case 'U': + return readCodePoint(8); + case '\n': + if (!multiline) { + throw new TomlException("a line cannot end inside a string", line); + } + // NB: a backslash before a newline swallows the whitespace after + // it, which is how TOML lets a long line be wrapped. + line++; + while (!eof() && Character.isWhitespace(peek())) { + if (next() == '\n') line++; + } + return ""; + default: + throw new TomlException("unknown escape '\\" + c + "'", line); + } + } + + private String readCodePoint(final int digits) { + if (i + digits > s.length()) { + throw new TomlException("unfinished escape", line); + } + final String hex = s.substring(i, i + digits); + i += digits; + try { + return new String(Character.toChars(Integer.parseInt(hex, 16))); + } + catch (final IllegalArgumentException exc) { + throw new TomlException("'" + hex + "' is not a code point", line); + } + } + + private String readComment() { + final int start = i; + while (!eof() && peek() != '\n') + next(); + return s.substring(start, i).stripTrailing(); + } + + /** Insists that nothing but a comment follows on this line. */ + private void endOfLine() { + skipSpace(); + if (eof()) return; + if (peek() == '#') { + // NB: a trailing comment belongs to the value it follows, and is + // kept above it rather than beside it. Better than losing it. + pending.add(readComment()); + return; + } + if (peek() != '\n') { + throw new TomlException("unexpected '" + peek() + "' at the end of a" + + " line", line); + } + next(); + line++; + } + + private void skipSpace() { + while (!eof() && (peek() == ' ' || peek() == '\t')) + next(); + } + + /** Inside an array, newlines and comments are just whitespace. */ + private void skipArraySpace() { + while (!eof()) { + final char c = peek(); + if (c == ' ' || c == '\t') next(); + else if (c == '\n') { + next(); + line++; + } + else if (c == '#') readComment(); + else return; + } + } + + private static boolean isBareKeyChar(final char c) { + return Character.isLetterOrDigit(c) && c < 128 || c == '_' || c == '-'; + } + + private boolean eof() { + return i >= s.length(); + } + + private char peek() { + return s.charAt(i); + } + + private char next() { + return s.charAt(i++); + } + } +} diff --git a/settings/src/main/java/org/scijava/settings/TomlException.java b/settings/src/main/java/org/scijava/settings/TomlException.java new file mode 100644 index 000000000..73c6c9f87 --- /dev/null +++ b/settings/src/main/java/org/scijava/settings/TomlException.java @@ -0,0 +1,56 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +/** + * Thrown when a settings file cannot be read. + *

+ * It names the line, because the file is one a person edits by hand and the + * whole point of saying "unsupported" out loud is that they can go and look. + *

+ * + * @author Curtis Rueden + */ +public class TomlException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int line; + + public TomlException(final String message, final int line) { + super("line " + line + ": " + message); + this.line = line; + } + + /** Gets the line the trouble was on. */ + public int line() { + return line; + } +} diff --git a/settings/src/test/java/org/scijava/settings/RememberedInputsTest.java b/settings/src/test/java/org/scijava/settings/RememberedInputsTest.java new file mode 100644 index 000000000..02c6e5294 --- /dev/null +++ b/settings/src/test/java/org/scijava/settings/RememberedInputsTest.java @@ -0,0 +1,191 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.invoke.MethodHandles; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +import org.junit.jupiter.api.Test; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Executables; +import org.scijava.execute.Parameter; +import org.scijava.execute.Preprocessor; +import org.scijava.execute.Runner; +import org.scijava.struct.ItemIO; + +/** + * Tests that a command remembers what the user chose. + * + * @author Curtis Rueden + */ +public class RememberedInputsTest { + + /** What was chosen once is filled in the next time. */ + @Test + public void testValuesCarryOver() throws Exception { + final Settings settings = Settings.inMemory(); + + run(settings, new Blur(), Map.of("sigma", 3.5, "edges", "Wrap")); + + final Blur second = new Blur(); + run(settings, second, Map.of()); + + assertEquals(3.5, second.sigma); + assertEquals("Wrap", second.edges); + } + + /** + * The values survive a run that failed, which is the whole reason saving + * happens before the run rather than after it. + */ + @Test + public void testValuesSurviveAFailedRun() { + final Settings settings = Settings.inMemory(); + + final ExecutionException failure = org.junit.jupiter.api.Assertions + .assertThrows(ExecutionException.class, () -> run(settings, new Blur(), + Map.of("sigma", 9.5, "edges", "Zero", "explode", true))); + assertTrue(failure.getCause().getMessage().contains("boom"), failure + .getCause().getMessage()); + + // NB: the user is about to try again, and this is exactly when being + // made to retype everything is most infuriating. + assertEquals(Optional.of(9.5), settings.get(Blur.class.getName(), "sigma", + Double.class)); + assertEquals(Optional.of("Zero"), settings.get(Blur.class.getName(), + "edgePolicy", String.class)); + } + + /** What the caller supplied wins over what was remembered. */ + @Test + public void testSuppliedValueWins() throws Exception { + final Settings settings = Settings.inMemory(); + run(settings, new Blur(), Map.of("sigma", 3.5, "edges", "Wrap")); + + final Blur second = new Blur(); + run(settings, second, Map.of("sigma", 1.0)); + + assertEquals(1.0, second.sigma, "the caller said so"); + assertEquals("Wrap", second.edges, "and the rest was remembered"); + } + + /** A parameter that says not to be remembered is not remembered. */ + @Test + public void testPersistFalse() throws Exception { + final Settings settings = Settings.inMemory(); + + run(settings, new Blur(), Map.of("sigma", 3.5, "edges", "Wrap", + "seed", 12345L)); + + assertFalse(settings.get(Blur.class.getName(), "seed", Long.class) + .isPresent(), "a one-off value should not carry over"); + } + + /** A parameter may be remembered under a name of its own choosing. */ + @Test + public void testPersistKey() throws Exception { + final Settings settings = Settings.inMemory(); + + run(settings, new Blur(), Map.of("sigma", 3.5, "edges", "Wrap")); + + assertEquals(Optional.of("Wrap"), settings.get(Blur.class.getName(), + "edgePolicy", String.class), "stored under its persistKey"); + } + + /** Outputs are not inputs, and are not remembered. */ + @Test + public void testOutputsAreNotRemembered() throws Exception { + final Settings settings = Settings.inMemory(); + + run(settings, new Blur(), Map.of("sigma", 3.5, "edges", "Wrap")); + + assertFalse(settings.get(Blur.class.getName(), "result", String.class) + .isPresent()); + } + + /** Persistence is a pair of processors, so a caller may leave them out. */ + @Test + public void testWithoutTheProcessors() throws Exception { + final Settings settings = Settings.inMemory(); + final Blur blur = new Blur(); + + Runner.of(List.of(), List.of()).run(Executables.executableOf(blur, + MethodHandles.lookup()), Map.of("sigma", 3.5, "edges", "Wrap")).get(); + + assertTrue(settings.toml().tables().stream().allMatch(t -> settings.toml() + .table(t).isEmpty()), "nothing was remembered"); + } + + // -- Helper methods -- + + private static ExecutionResult run(final Settings settings, + final Blur command, final Map inputs) throws Exception + { + final List chain = List.of(new LoadInputs(settings), + new SaveInputs(settings)); + // NB: this test's own lookup, rather than the module opening a package + // it has no plugins in. A container would supply one; here the test is + // the container. + return Runner.of(chain, List.of()).run(Executables.executableOf(command, + MethodHandles.lookup()), inputs).get(); + } + + /** A command with something worth remembering, and something not. */ + public static class Blur implements Runnable { + + @Parameter + private double sigma = 2; + + @Parameter(persistKey = "edgePolicy") + private String edges = "Reflect"; + + @Parameter(persist = false, required = false) + private Long seed; + + @Parameter(required = false) + private boolean explode; + + @Parameter(io = ItemIO.OUTPUT) + private String result; + + @Override + public void run() { + if (explode) throw new IllegalStateException("boom"); + result = "blurred " + sigma + " " + edges; + } + } +} diff --git a/settings/src/test/java/org/scijava/settings/SettingsTest.java b/settings/src/test/java/org/scijava/settings/SettingsTest.java new file mode 100644 index 000000000..e083cc876 --- /dev/null +++ b/settings/src/test/java/org/scijava/settings/SettingsTest.java @@ -0,0 +1,193 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.scijava.common3.Types; + +/** + * Tests the settings store. + * + * @author Curtis Rueden + */ +public class SettingsTest { + + /** The same path on every platform, and one property to override it. */ + @Test + public void testWhereSettingsLive() { + final Path byApp = Settings.fileFor("fiji"); + assertEquals("settings.toml", byApp.getFileName().toString()); + assertEquals("fiji", byApp.getParent().getFileName().toString()); + // NB: .config even on macOS and Windows, deliberately. + assertEquals(".config", byApp.getParent().getParent().getFileName() + .toString()); + + System.setProperty(Settings.DIR_PROPERTY, "/tmp/elsewhere"); + try { + assertEquals(Path.of("/tmp/elsewhere/settings.toml"), Settings.fileFor( + "fiji")); + } + finally { + System.clearProperty(Settings.DIR_PROPERTY); + } + } + + /** A value comes back as the type the caller asks for. */ + @Test + public void testTypedAccess() { + final Settings settings = Settings.inMemory(); + settings.set("t", "sigma", 2.5); + settings.set("t", "count", 3); + settings.set("t", "name", "ada"); + settings.set("t", "on", true); + settings.set("t", "when", LocalDate.of(2026, 9, 18)); + + assertEquals(Optional.of(2.5), settings.get("t", "sigma", Double.class)); + assertEquals(Optional.of(3), settings.get("t", "count", Integer.class)); + assertEquals(Optional.of("ada"), settings.get("t", "name", String.class)); + assertEquals(Optional.of(true), settings.get("t", "on", Boolean.class)); + assertEquals(Optional.of(LocalDate.of(2026, 9, 18)), settings.get("t", + "when", LocalDate.class)); + } + + /** A file round-trips, through the converter a dialog would use. */ + @Test + public void testFilesRoundTrip() { + final Settings settings = Settings.inMemory(); + settings.set("t", "input", new File("/data/a.tif")); + + assertEquals("/data/a.tif", settings.get("t", "input")); + assertEquals(Optional.of(new File("/data/a.tif")), settings.get("t", + "input", File.class)); + } + + /** + * A list of files is an array of strings, not one mangled string - so a + * {@code List} parameter comes back as one. + */ + @Test + public void testCollectionsAreArrays() { + final Settings settings = Settings.inMemory(); + settings.set("t", "inputs", List.of(new File("/a.tif"), new File( + "/b.tif"))); + + assertEquals(List.of("/a.tif", "/b.tif"), settings.get("t", "inputs")); + assertEquals(Optional.of(List.of(new File("/a.tif"), new File("/b.tif"))), + settings.get("t", "inputs", Types.parameterize(List.class, File.class))); + } + + /** An enum is its name, and comes back an enum. */ + @Test + public void testEnums() { + final Settings settings = Settings.inMemory(); + settings.set("t", "edges", Edges.WRAP); + + assertEquals("WRAP", settings.get("t", "edges")); + assertEquals(Optional.of(Edges.WRAP), settings.get("t", "edges", + Edges.class)); + } + + /** Arbitrary precision keeps its precision, so it is stored as text. */ + @Test + public void testExactNumbers() { + final Settings settings = Settings.inMemory(); + settings.set("t", "exact", new BigDecimal("0.1000000000000000000001")); + + assertEquals(Optional.of(new BigDecimal("0.1000000000000000000001")), + settings.get("t", "exact", BigDecimal.class)); + } + + /** What cannot be stored says so rather than storing nonsense. */ + @Test + public void testUnstorableValue() { + final Settings settings = Settings.inMemory(); + + assertFalse(settings.set("t", "thing", new Object() { + // NB: an anonymous class whose toString is its identity would store a + // string that means nothing tomorrow. + }) && false, "stored as a string, and useless to read back"); + } + + /** Saving writes a file a person can read, and keeps their comments. */ + @Test + public void testSaveAndReload(@TempDir final Path dir) throws Exception { + final Path file = dir.resolve("nested").resolve("settings.toml"); + final Settings settings = Settings.of(file); + settings.set("sc.fiji.commands.Blur", "sigma", 0.7); + settings.save(); + + final String written = new String(Files.readAllBytes(file), + StandardCharsets.UTF_8); + assertTrue(written.contains("[sc.fiji.commands.Blur]"), written); + assertTrue(written.contains("sigma = 0.7"), written); + + // a person edits it, comment and all + Files.write(file, written.replace("sigma = 0.7", + "# the detector is noisy\nsigma = 1.4").getBytes(StandardCharsets.UTF_8)); + + final Settings reloaded = Settings.of(file); + assertEquals(Optional.of(1.4), reloaded.get("sc.fiji.commands.Blur", + "sigma", Double.class)); + + reloaded.set("sc.fiji.commands.Blur", "sigma", 2.1); + reloaded.save(); + final String again = new String(Files.readAllBytes(file), + StandardCharsets.UTF_8); + assertTrue(again.contains("# the detector is noisy"), again); + assertTrue(again.contains("sigma = 2.1"), again); + } + + /** In-memory settings have nowhere to save to, and saving is not an error. */ + @Test + public void testInMemory() { + final Settings settings = Settings.inMemory(); + settings.set("t", "k", "v"); + settings.save(); // NB: does nothing, quietly + assertEquals(Optional.empty(), settings.file()); + } + + public enum Edges { + REFLECT, ZERO, WRAP + } +} diff --git a/settings/src/test/java/org/scijava/settings/TomlTest.java b/settings/src/test/java/org/scijava/settings/TomlTest.java new file mode 100644 index 000000000..9e66f8d40 --- /dev/null +++ b/settings/src/test/java/org/scijava/settings/TomlTest.java @@ -0,0 +1,213 @@ +/* + * #%L + * Settings a user can read and edit, in one TOML file. + * %% + * Copyright (C) 2026 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.settings; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests reading and writing the TOML a settings file is made of. + * + * @author Curtis Rueden + */ +public class TomlTest { + + /** The shapes a settings file is actually made of. */ + @Test + public void testTablesAndScalars() { + final Toml toml = Toml.parse("" + // + "[sc.fiji.commands.Blur]\n" + // + "sigma = 2.5\n" + // + "iterations = 3\n" + // + "edges = \"Reflect\"\n" + // + "verbose = true\n"); + + assertEquals(List.of("", "sc.fiji.commands.Blur"), toml.tables()); + assertEquals(2.5, toml.get("sc.fiji.commands.Blur", "sigma")); + assertEquals(3L, toml.get("sc.fiji.commands.Blur", "iterations")); + assertEquals("Reflect", toml.get("sc.fiji.commands.Blur", "edges")); + assertEquals(true, toml.get("sc.fiji.commands.Blur", "verbose")); + } + + /** A script's identifier is a path, so its table name is quoted. */ + @Test + public void testQuotedTableName() { + final Toml toml = Toml.parse( // + "[\"script:/home/curtis/Blur_It.groovy\"]\nsigma = 1.0\n"); + + assertEquals(1.0, toml.get("script:/home/curtis/Blur_It.groovy", "sigma")); + } + + /** Numbers in every spelling TOML allows. */ + @Test + public void testNumbers() { + final Toml toml = Toml.parse("" + // + "a = 1_000_000\n" + "b = 0xff\n" + "c = 0o755\n" + "d = 0b1010\n" + // + "e = -2.5e3\n" + "f = inf\n" + "g = -inf\n" + "h = nan\n"); + + assertEquals(1000000L, toml.get(Toml.ROOT, "a")); + assertEquals(255L, toml.get(Toml.ROOT, "b")); + assertEquals(493L, toml.get(Toml.ROOT, "c")); + assertEquals(10L, toml.get(Toml.ROOT, "d")); + assertEquals(-2500.0, toml.get(Toml.ROOT, "e")); + assertEquals(Double.POSITIVE_INFINITY, toml.get(Toml.ROOT, "f")); + assertEquals(Double.NEGATIVE_INFINITY, toml.get(Toml.ROOT, "g")); + assertTrue(Double.isNaN((Double) toml.get(Toml.ROOT, "h"))); + } + + /** All four date-and-time types, which a date widget will want. */ + @Test + public void testMoments() { + final Toml toml = Toml.parse("" + // + "date = 2026-09-18\n" + // + "time = 14:30:00\n" + // + "stamp = 2026-09-18T14:30:00\n" + // + "zoned = 2026-09-18T14:30:00+02:00\n"); + + assertEquals(LocalDate.of(2026, 9, 18), toml.get(Toml.ROOT, "date")); + assertEquals(LocalTime.of(14, 30), toml.get(Toml.ROOT, "time")); + assertEquals(LocalDateTime.of(2026, 9, 18, 14, 30), toml.get(Toml.ROOT, + "stamp")); + assertEquals(OffsetDateTime.parse("2026-09-18T14:30:00+02:00"), toml.get( + Toml.ROOT, "zoned")); + } + + /** Strings in all four spellings, including what a text area holds. */ + @Test + public void testStrings() { + final Toml toml = Toml.parse("" + // + "basic = \"a\\tb\\nc\"\n" + // + "literal = 'C:\\Users\\curtis'\n" + // + "notes = \"\"\"\nline one\nline two\n\"\"\"\n" + // + "raw = '''\nas written\n'''\n"); + + assertEquals("a\tb\nc", toml.get(Toml.ROOT, "basic")); + assertEquals("C:\\Users\\curtis", toml.get(Toml.ROOT, "literal")); + assertEquals("line one\nline two\n", toml.get(Toml.ROOT, "notes")); + assertEquals("as written\n", toml.get(Toml.ROOT, "raw")); + } + + /** Arrays, which is how a List<File> parameter persists. */ + @Test + public void testArrays() { + final Toml toml = Toml.parse("" + // + "inputs = [\"/data/a.tif\", \"/data/b.tif\"]\n" + // + "weights = [\n 1.0,\n 2.0, # the second one\n 3.0,\n]\n"); + + assertEquals(List.of("/data/a.tif", "/data/b.tif"), toml.get(Toml.ROOT, + "inputs")); + assertEquals(List.of(1.0, 2.0, 3.0), toml.get(Toml.ROOT, "weights")); + } + + /** What a person wrote in the file is still there after a save. */ + @Test + public void testCommentsSurviveAWrite() { + final String original = "" + // + "# Settings for my analysis\n" + // + "\n" + // + "[sc.fiji.commands.Blur]\n" + // + "# 0.7 because the detector is noisy\n" + // + "sigma = 0.7\n"; + + final Toml toml = Toml.parse(original); + toml.set("sc.fiji.commands.Blur", "sigma", 1.4); + final String written = toml.write(); + + assertTrue(written.contains("# Settings for my analysis"), written); + assertTrue(written.contains("# 0.7 because the detector is noisy"), + written); + assertTrue(written.contains("sigma = 1.4"), written); + } + + /** Everything written can be read back as what it was. */ + @Test + public void testRoundTrip() { + final Toml toml = Toml.empty(); + toml.set("a.b.C", "text", "hello"); + toml.set("a.b.C", "multi", "one\ntwo"); + toml.set("a.b.C", "count", 42L); + toml.set("a.b.C", "ratio", 0.25); + toml.set("a.b.C", "on", true); + toml.set("a.b.C", "when", LocalDate.of(2026, 9, 18)); + toml.set("a.b.C", "files", List.of("/a", "/b")); + toml.set("with spaces", "key", "value"); + + final Toml again = Toml.parse(toml.write()); + + assertEquals("hello", again.get("a.b.C", "text")); + assertEquals("one\ntwo", again.get("a.b.C", "multi")); + assertEquals(42L, again.get("a.b.C", "count")); + assertEquals(0.25, again.get("a.b.C", "ratio")); + assertEquals(true, again.get("a.b.C", "on")); + assertEquals(LocalDate.of(2026, 9, 18), again.get("a.b.C", "when")); + assertEquals(List.of("/a", "/b"), again.get("a.b.C", "files")); + assertEquals("value", again.get("with spaces", "key")); + } + + /** What is not supported says so, with a line number to look at. */ + @Test + public void testUnsupportedConstructs() { + final TomlException inline = assertThrows(TomlException.class, // + () -> Toml.parse("[t]\nx = 1\npoint = { a = 1, b = 2 }\n")); + assertEquals(3, inline.line()); + assertTrue(inline.getMessage().contains("inline tables"), inline + .getMessage()); + + final TomlException arrayOfTables = assertThrows(TomlException.class, // + () -> Toml.parse("[[products]]\nname = \"x\"\n")); + assertTrue(arrayOfTables.getMessage().contains("arrays of tables"), + arrayOfTables.getMessage()); + } + + /** A malformed file names the line rather than guessing. */ + @Test + public void testMalformed() { + final TomlException exc = assertThrows(TomlException.class, // + () -> Toml.parse("[t]\ngood = 1\nbad value\n")); + assertEquals(3, exc.line()); + } + + /** A value TOML cannot hold is refused when it is set, not when written. */ + @Test + public void testUnrepresentableValue() { + final Toml toml = Toml.empty(); + assertThrows(IllegalArgumentException.class, // + () -> toml.set("t", "k", new Object())); + } +} diff --git a/spi/pom.xml b/spi/pom.xml new file mode 100644 index 000000000..4889cf69d --- /dev/null +++ b/spi/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-spi + 1.0.0-SNAPSHOT + + SciJava SPI + Minimal interfaces implementable by any SciJava-friendly object. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.spi + + bsd_2 + SciJava developers. + + + + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/spi/src/main/java/module-info.java b/spi/src/main/java/module-info.java new file mode 100644 index 000000000..5f4fbd6cf --- /dev/null +++ b/spi/src/main/java/module-info.java @@ -0,0 +1,34 @@ +/* + * #%L + * Minimal interfaces implementable by any SciJava-friendly object. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.spi { + + exports org.scijava.spi; + +} diff --git a/spi/src/main/java/org/scijava/spi/Disposable.java b/spi/src/main/java/org/scijava/spi/Disposable.java new file mode 100644 index 000000000..363b50169 --- /dev/null +++ b/spi/src/main/java/org/scijava/spi/Disposable.java @@ -0,0 +1,44 @@ +/* + * #%L + * Minimal interfaces implementable by any SciJava-friendly object. + * %% + * Copyright (C) 2026 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.spi; + +/** + * An object that knows how to clean up after itself. + * + * @author Curtis Rueden + */ +public interface Disposable { + + /** + * Performs any needed cleanup, in preparation for the object being retired - + * for example, to make garbage collection possible. + */ + void dispose(); +} diff --git a/scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java b/spi/src/main/java/org/scijava/spi/GenericTyped.java similarity index 91% rename from scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java rename to spi/src/main/java/org/scijava/spi/GenericTyped.java index 2ae41be36..782d51bbb 100644 --- a/scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java +++ b/spi/src/main/java/org/scijava/spi/GenericTyped.java @@ -1,8 +1,8 @@ /* * #%L - * Common functionality widely used across SciJava modules. + * Minimal interfaces implementable by any SciJava-friendly object. * %% - * Copyright (C) 2021 - 2025 SciJava developers. + * Copyright (C) 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.common3; +package org.scijava.spi; import java.lang.reflect.Type; diff --git a/spi/src/main/java/org/scijava/spi/Identifiable.java b/spi/src/main/java/org/scijava/spi/Identifiable.java new file mode 100644 index 000000000..7e9d0953f --- /dev/null +++ b/spi/src/main/java/org/scijava/spi/Identifiable.java @@ -0,0 +1,47 @@ +/* + * #%L + * Minimal interfaces implementable by any SciJava-friendly object. + * %% + * Copyright (C) 2026 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.spi; + +/** + * An object that can be identified by a string. + *

+ * An identifier is a weak form of long-term serialization: an object's + * identifier can be written to durable storage, and later - in a new JVM - used + * to query an index for the object it names. Neither serializability nor + * {@link Object#equals} comparison of freshly synthesized objects is needed. + *

+ * + * @author Curtis Rueden + */ +public interface Identifiable { + + /** Gets a string intended to function as an identifier for the object. */ + String id(); +} diff --git a/spi/src/main/java/org/scijava/spi/Named.java b/spi/src/main/java/org/scijava/spi/Named.java new file mode 100644 index 000000000..ac12d4f6a --- /dev/null +++ b/spi/src/main/java/org/scijava/spi/Named.java @@ -0,0 +1,48 @@ +/* + * #%L + * Minimal interfaces implementable by any SciJava-friendly object. + * %% + * Copyright (C) 2026 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.spi; + +/** + * An object that knows its name. + *

+ * Note that naming an object is deliberately not part of this + * contract: an implementation whose name can change should expose its own + * setter, rather than every caller having to discover at runtime whether this + * particular {@code Named} happens to be mutable. + *

+ * + * @author Lee Kamentsky + * @author Curtis Rueden + */ +public interface Named { + + /** Gets the name of the object. */ + String name(); +} diff --git a/scijava-common3/src/main/java/org/scijava/common3/Versioned.java b/spi/src/main/java/org/scijava/spi/Versioned.java similarity index 91% rename from scijava-common3/src/main/java/org/scijava/common3/Versioned.java rename to spi/src/main/java/org/scijava/spi/Versioned.java index ac0d77d63..7b133eb58 100644 --- a/scijava-common3/src/main/java/org/scijava/common3/Versioned.java +++ b/spi/src/main/java/org/scijava/spi/Versioned.java @@ -1,8 +1,8 @@ /* * #%L - * Common functionality widely used across SciJava modules. + * Minimal interfaces implementable by any SciJava-friendly object. * %% - * Copyright (C) 2021 - 2025 SciJava developers. + * Copyright (C) 2026 SciJava developers. * %% * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.common3; +package org.scijava.spi; /** * An object that knows its version. diff --git a/scijava-struct/LICENSE.txt b/struct/LICENSE.txt similarity index 100% rename from scijava-struct/LICENSE.txt rename to struct/LICENSE.txt diff --git a/scijava-struct/README.md b/struct/README.md similarity index 62% rename from scijava-struct/README.md rename to struct/README.md index b4dec0d38..06d2c0839 100644 --- a/scijava-struct/README.md +++ b/struct/README.md @@ -12,6 +12,6 @@ This library provides a lightweight framework for collecting sets of typed field ## So what? How is this useful? -While `Struct`s can be valuable in describing `Method`s, they can be particuarly useful when describing `Class`es and shine under a [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) paradigm. In such situations, we need a data structure to define **all** inputs to the injectable `Class`, be they constructor or `FunctionalInterface` arguments, or simply `Field`s that must be injected beforehand. +While `Struct`s can be valuable in describing `Method`s, they can be particularly useful when describing `Class`es and shine under a [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) paradigm. In such situations, we need a data structure to define **all** inputs to the injectable `Class`, be they constructor or `FunctionalInterface` arguments, or simply `Field`s that must be injected beforehand. To see SciJava Struct in action, check out the SciJava Ops project! diff --git a/scijava-struct/pom.xml b/struct/pom.xml similarity index 100% rename from scijava-struct/pom.xml rename to struct/pom.xml diff --git a/scijava-struct/src/main/java/module-info.java b/struct/src/main/java/module-info.java similarity index 100% rename from scijava-struct/src/main/java/module-info.java rename to struct/src/main/java/module-info.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/ItemIO.java b/struct/src/main/java/org/scijava/struct/ItemIO.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/ItemIO.java rename to struct/src/main/java/org/scijava/struct/ItemIO.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/Member.java b/struct/src/main/java/org/scijava/struct/Member.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/Member.java rename to struct/src/main/java/org/scijava/struct/Member.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/MemberInstance.java b/struct/src/main/java/org/scijava/struct/MemberInstance.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/MemberInstance.java rename to struct/src/main/java/org/scijava/struct/MemberInstance.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/MemberParser.java b/struct/src/main/java/org/scijava/struct/MemberParser.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/MemberParser.java rename to struct/src/main/java/org/scijava/struct/MemberParser.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/Struct.java b/struct/src/main/java/org/scijava/struct/Struct.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/Struct.java rename to struct/src/main/java/org/scijava/struct/Struct.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/StructInstance.java b/struct/src/main/java/org/scijava/struct/StructInstance.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/StructInstance.java rename to struct/src/main/java/org/scijava/struct/StructInstance.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/Structs.java b/struct/src/main/java/org/scijava/struct/Structs.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/Structs.java rename to struct/src/main/java/org/scijava/struct/Structs.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/ValueAccessible.java b/struct/src/main/java/org/scijava/struct/ValueAccessible.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/ValueAccessible.java rename to struct/src/main/java/org/scijava/struct/ValueAccessible.java diff --git a/scijava-struct/src/main/java/org/scijava/struct/ValueAccessibleMemberInstance.java b/struct/src/main/java/org/scijava/struct/ValueAccessibleMemberInstance.java similarity index 100% rename from scijava-struct/src/main/java/org/scijava/struct/ValueAccessibleMemberInstance.java rename to struct/src/main/java/org/scijava/struct/ValueAccessibleMemberInstance.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/AnnotatedParameterMember.java b/struct/src/test/java/org/scijava/struct/AnnotatedParameterMember.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/AnnotatedParameterMember.java rename to struct/src/test/java/org/scijava/struct/AnnotatedParameterMember.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/Attr.java b/struct/src/test/java/org/scijava/struct/Attr.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/Attr.java rename to struct/src/test/java/org/scijava/struct/Attr.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/DefaultMutableParameterMember.java b/struct/src/test/java/org/scijava/struct/DefaultMutableParameterMember.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/DefaultMutableParameterMember.java rename to struct/src/test/java/org/scijava/struct/DefaultMutableParameterMember.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/FieldParameterMember.java b/struct/src/test/java/org/scijava/struct/FieldParameterMember.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/FieldParameterMember.java rename to struct/src/test/java/org/scijava/struct/FieldParameterMember.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/FunctionalParameterMember.java b/struct/src/test/java/org/scijava/struct/FunctionalParameterMember.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/FunctionalParameterMember.java rename to struct/src/test/java/org/scijava/struct/FunctionalParameterMember.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/ItemVisibility.java b/struct/src/test/java/org/scijava/struct/ItemVisibility.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/ItemVisibility.java rename to struct/src/test/java/org/scijava/struct/ItemVisibility.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/MutableParameterMember.java b/struct/src/test/java/org/scijava/struct/MutableParameterMember.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/MutableParameterMember.java rename to struct/src/test/java/org/scijava/struct/MutableParameterMember.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/Parameter.java b/struct/src/test/java/org/scijava/struct/Parameter.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/Parameter.java rename to struct/src/test/java/org/scijava/struct/Parameter.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/ParameterMember.java b/struct/src/test/java/org/scijava/struct/ParameterMember.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/ParameterMember.java rename to struct/src/test/java/org/scijava/struct/ParameterMember.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/ParameterStructs.java b/struct/src/test/java/org/scijava/struct/ParameterStructs.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/ParameterStructs.java rename to struct/src/test/java/org/scijava/struct/ParameterStructs.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/Parameters.java b/struct/src/test/java/org/scijava/struct/Parameters.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/Parameters.java rename to struct/src/test/java/org/scijava/struct/Parameters.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/StructTest.java b/struct/src/test/java/org/scijava/struct/StructTest.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/StructTest.java rename to struct/src/test/java/org/scijava/struct/StructTest.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/ValidityException.java b/struct/src/test/java/org/scijava/struct/ValidityException.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/ValidityException.java rename to struct/src/test/java/org/scijava/struct/ValidityException.java diff --git a/scijava-struct/src/test/java/org/scijava/struct/ValidityProblem.java b/struct/src/test/java/org/scijava/struct/ValidityProblem.java similarity index 100% rename from scijava-struct/src/test/java/org/scijava/struct/ValidityProblem.java rename to struct/src/test/java/org/scijava/struct/ValidityProblem.java diff --git a/swing/pom.xml b/swing/pom.xml new file mode 100644 index 000000000..a18270c5c --- /dev/null +++ b/swing/pom.xml @@ -0,0 +1,254 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-swing + 1.0.0-SNAPSHOT + + SciJava Swing + Swing widgets, and a dialog to harvest inputs with them. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.swing + + bsd_2 + SciJava developers. + + + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-command3 + ${scijava-command3.version} + + + org.scijava + scijava-context + ${scijava-context.version} + + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-index + ${scijava-index.version} + + + org.scijava + scijava-ui3 + ${scijava-ui3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-harvest + ${scijava-harvest.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.slf4j + slf4j-api + + + + + + org.scijava + scijava-script3 + ${scijava-script3.version} + test + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + + + org.scijava + scijava-ui3-test + ${scijava-ui3-test.version} + test + + + + org.slf4j + slf4j-simple + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/swing/src/main/java/module-info.java b/swing/src/main/java/module-info.java new file mode 100644 index 000000000..0225a176f --- /dev/null +++ b/swing/src/main/java/module-info.java @@ -0,0 +1,46 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.swing { + + exports org.scijava.swing; + + // NB: one opens, to the container alone, so that it can inject and + // construct these plugins. `opens` is not `exports`. + opens org.scijava.swing to org.scijava.context; + + requires transitive java.desktop; + requires transitive org.scijava.ui3; + requires transitive org.scijava.command3; + requires transitive org.scijava.context; + requires org.scijava.discovery; + requires org.scijava.priority; + requires org.slf4j; + +} diff --git a/swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java b/swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java new file mode 100644 index 000000000..1a5c0d228 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java @@ -0,0 +1,116 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.event.ItemEvent; +import java.util.List; +import java.util.Objects; + +import javax.swing.DefaultComboBoxModel; +import javax.swing.JComboBox; +import javax.swing.JComponent; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; + +/** + * A widget for a parameter with a known set of values, declared or computed. + *

+ * NB: it asks the node for its {@link ParameterNode#choices() choices} each + * time it refreshes, so a parameter whose values depend on another - the axes + * of the chosen image, the columns of the chosen table - simply works. In + * SciJava Common this needed a {@code DynamicCommand}. + *

+ * + * @author Curtis Rueden + */ +public class SwingChoiceWidget extends SwingWidget { + + private final JComboBox comboBox; + + public SwingChoiceWidget(final ParameterNode node, + final ParameterModel model) + { + super(node, model); + comboBox = new JComboBox<>(); + refresh(); + comboBox.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + update(comboBox.getSelectedItem()); + } + }); + } + + @Override + public JComponent component() { + return comboBox; + } + + @Override + protected void doRefresh() { + final List choices = node().choices(); + if (!sameItems(choices)) { + comboBox.setModel(new DefaultComboBoxModel<>(choices.toArray())); + } + final Object value = value(); + final Object selected = value == null ? null : asChoice(value, choices); + if (!Objects.equals(comboBox.getSelectedItem(), selected)) { + comboBox.setSelectedItem(selected); + } + } + + // -- Helper methods -- + + private boolean sameItems(final List choices) { + if (comboBox.getItemCount() != choices.size()) return false; + for (int i = 0; i < choices.size(); i++) { + if (!Objects.equals(comboBox.getItemAt(i), choices.get(i))) return false; + } + return true; + } + + /** + * Finds the choice a value stands for. + *

+ * NB: declared choices arrive as strings, while the parameter may hold a + * number or an enum constant, so matching on {@code toString} is what keeps + * the right item selected. + *

+ */ + private Object asChoice(final Object value, final List choices) { + for (final Object choice : choices) { + if (Objects.equals(choice, value)) return choice; + } + for (final Object choice : choices) { + if (String.valueOf(choice).equals(String.valueOf(value))) return choice; + } + return null; + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java new file mode 100644 index 000000000..bb301a992 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java @@ -0,0 +1,64 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; + +/** + * Makes {@link SwingChoiceWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingChoiceWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return !node.choices().isEmpty(); + } + + @Override + public SwingWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new SwingChoiceWidget(node, model); + } + + @Override + public double priority() { + // NB: a fixed set of values beats whatever the type would otherwise get. + return Priority.HIGH; + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingDialog.java b/swing/src/main/java/org/scijava/swing/SwingDialog.java new file mode 100644 index 000000000..f6a58a779 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingDialog.java @@ -0,0 +1,226 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Window; +import java.util.List; +import java.util.Map; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingUtilities; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanels; + +/** + * The dialog a user fills in: the parameters, an OK and a Cancel. + *

+ * It rebuilds itself whenever the shape of the parameters changes, which is + * how a value that reveals a group, or decides how many parameters there are, + * takes effect as the user types. + *

+ * + * @author Curtis Rueden + */ +public class SwingDialog { + + private static final Logger log = LoggerFactory.getLogger(SwingDialog.class); + + private final ParameterModel model; + private final WidgetPanels builder; + private final JDialog dialog; + private final JPanel content; + private final JButton okButton; + private final JLabel problem; + + private SwingPanel panel; + private boolean accepted; + private boolean rebuilding; + private boolean placed; + + public SwingDialog(final Window owner, final String title, + final ParameterModel model, + final List> factories) + { + this.model = model; + builder = new WidgetPanels<>(factories, new SwingPanelFactory(model)); + + dialog = new JDialog(owner, title, JDialog.ModalityType.APPLICATION_MODAL); + content = new JPanel(new BorderLayout()); + content.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + problem = new JLabel(" "); + problem.setForeground(java.awt.Color.RED.darker()); + + okButton = new JButton("OK"); + okButton.addActionListener(e -> { + accepted = true; + dialog.dispose(); + }); + final JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(e -> dialog.dispose()); + + final JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + buttons.add(okButton); + buttons.add(cancelButton); + + final JPanel south = new JPanel(new BorderLayout()); + south.add(problem, BorderLayout.CENTER); + south.add(buttons, BorderLayout.EAST); + + dialog.setLayout(new BorderLayout()); + dialog.add(content, BorderLayout.CENTER); + dialog.add(south, BorderLayout.SOUTH); + dialog.getRootPane().setDefaultButton(okButton); + + // NB: a value may reshape the dialog - reveal a group, add parameters - + // so the tree is rebuilt on every change and the widgets follow it. + model.onTreeChanged(tree -> SwingUtilities.invokeLater(() -> { + log.debug("Parameter tree changed; rebuilding widgets"); + rebuild(); + })); + // NB: a callback may change values other than the one the user touched, + // with the shape unchanged; those widgets still have to catch up. + model.values().onChange(change -> SwingUtilities.invokeLater( + this::refresh)); + + rebuild(); + } + + /** + * Shows the dialog and waits for the user. + * + * @return true if the user accepted, false if they cancelled + */ + public boolean showDialog() { + dialog.setVisible(true); // NB: modal, so this blocks until disposed + return accepted; + } + + /** Gets the dialog itself, to position or decorate it. */ + public JDialog dialog() { + return dialog; + } + + // -- Helper methods -- + + /** Builds the widgets afresh, for the shape the values now imply. */ + private void rebuild() { + if (rebuilding) return; + rebuilding = true; + try { + panel = (SwingPanel) builder.build(model); + log.debug("Built {} widget(s) for {}", panel.widgets().size(), dialog + .getTitle()); + if (panel.widgets().isEmpty()) { + // NB: an empty dialog is what a missing annotation index looks like + // from the outside, and it is otherwise entirely silent. + log.warn("No widgets for {}: either it declares no inputs, or no " + + "widget factory accepted them", dialog.getTitle()); + } + content.removeAll(); + // NB: the widgets hug the top. A GridBagLayout centers its rows in + // whatever height it is given, so a dialog dragged taller would + // otherwise split the new space above and below them. + final JPanel top = new JPanel(new BorderLayout()); + top.setOpaque(false); + top.add(panel.component(), BorderLayout.NORTH); + final JScrollPane scroll = new JScrollPane(top); + scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.getViewport().setOpaque(false); + scroll.setOpaque(false); + content.add(scroll, BorderLayout.CENTER); + validate(); + resize(); + content.revalidate(); + content.repaint(); + } + finally { + rebuilding = false; + } + } + + /** + * Sizes the dialog for the widgets it now holds. + *

+ * NB: only the first build places the dialog. Afterwards it may grow, to + * make room for parameters that have just appeared, but it never moves and + * never shrinks: a dialog that jumped back to the middle of the screen, or + * closed up around a group the user had just collapsed, would move the + * controls out from under the pointer. + *

+ */ + private void resize() { + final Dimension before = dialog.getSize(); + dialog.pack(); + final Dimension packed = dialog.getSize(); + if (placed) { + dialog.setSize(Math.max(before.width, packed.width), // + Math.max(before.height, packed.height)); + return; + } + placed = true; + // NB: keep a dialog with many parameters on the screen; the scroll pane + // takes care of the rest. + final Dimension screen = dialog.getGraphicsConfiguration() == null ? null + : dialog.getGraphicsConfiguration().getBounds().getSize(); + if (screen != null && packed.height > screen.height * 0.8) { + dialog.setSize(packed.width + 20, (int) (screen.height * 0.8)); + } + dialog.setLocationRelativeTo(dialog.getOwner()); + } + + private void refresh() { + if (rebuilding || panel == null) return; + panel.refresh(); + validate(); + } + + /** Shows what is wrong, and lets the user proceed only when nothing is. */ + private void validate() { + final Map problems = model.problems(); + panel.showProblems(problems); + okButton.setEnabled(problems.isEmpty()); + problem.setText(problems.isEmpty() ? " " : problems.values().iterator() + .next()); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingFileWidget.java b/swing/src/main/java/org/scijava/swing/SwingFileWidget.java new file mode 100644 index 000000000..4a3668857 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingFileWidget.java @@ -0,0 +1,126 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.BorderLayout; +import java.io.File; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.Widgets; + +/** + * A widget for a file or directory: a path to type, and a button to browse. + * + * @author Curtis Rueden + */ +public class SwingFileWidget extends SwingWidget implements DocumentListener { + + /** Style hint: choose a directory rather than a file. */ + public static final String DIRECTORY = "directory"; + + /** Style hint: choose where to save, rather than what to open. */ + public static final String SAVE = "save"; + + private final JTextField path; + private final JPanel component; + + public SwingFileWidget(final ParameterNode node, + final ParameterModel model) + { + super(node, model); + path = new JTextField(20); + final JButton browse = new JButton("Browse..."); + browse.addActionListener(e -> browse()); + + component = panel(); + component.setLayout(new BorderLayout(4, 0)); + component.add(path, BorderLayout.CENTER); + component.add(browse, BorderLayout.EAST); + + refresh(); + path.getDocument().addDocumentListener(this); + } + + @Override + public JComponent component() { + return component; + } + + @Override + public void insertUpdate(final DocumentEvent e) { + changed(); + } + + @Override + public void removeUpdate(final DocumentEvent e) { + changed(); + } + + @Override + public void changedUpdate(final DocumentEvent e) { + changed(); + } + + @Override + protected void doRefresh() { + final Object value = value(); + final String text = value == null ? "" : value.toString(); + if (!text.equals(path.getText())) path.setText(text); + } + + // -- Helper methods -- + + private void changed() { + final String text = path.getText(); + update(text.isEmpty() ? null : new File(text)); + } + + private void browse() { + final JFileChooser chooser = new JFileChooser(); + final String text = path.getText(); + if (!text.isEmpty()) chooser.setSelectedFile(new File(text)); + if (Widgets.isStyle(node(), DIRECTORY)) { + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + } + final int result = Widgets.isStyle(node(), SAVE) // + ? chooser.showSaveDialog(component) : chooser.showOpenDialog(component); + if (result != JFileChooser.APPROVE_OPTION) return; + path.setText(chooser.getSelectedFile().getPath()); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java new file mode 100644 index 000000000..5b13dee85 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java @@ -0,0 +1,60 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.io.File; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * Makes {@link SwingFileWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingFileWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return node.choices().isEmpty() && Widgets.type(node) == File.class; + } + + @Override + public SwingWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new SwingFileWidget(node, model); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingInputHarvester.java b/swing/src/main/java/org/scijava/swing/SwingInputHarvester.java new file mode 100644 index 000000000..92c415918 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingInputHarvester.java @@ -0,0 +1,150 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.swing.SwingUtilities; + +import org.scijava.context.Context; +import org.scijava.context.Dependency; +import org.scijava.context.Plugin; +import org.scijava.discovery.Discovery; +import org.scijava.execute.Preprocessor; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.InputHarvester; +import org.scijava.ui3.WidgetFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Asks the user for a run's remaining inputs, in a Swing dialog. + * + * @author Curtis Rueden + */ +@Plugin(type = Preprocessor.class) +public class SwingInputHarvester extends InputHarvester { + + private static final Logger log = LoggerFactory.getLogger( + SwingInputHarvester.class); + + @Dependency(required = false) + private Context context; + + private List> factories; + + private String title = "Inputs"; + + /** Creates a harvester whose widgets come from the container. */ + public SwingInputHarvester() { + // NB: the container injects the context and discovers the widgets. + } + + /** Creates a harvester with the given widgets, and no container. */ + public SwingInputHarvester( + final List> factories) + { + this.factories = List.copyOf(factories); + } + + /** Creates a harvester whose widgets come from the given container. */ + public static SwingInputHarvester of(final Context context) { + final SwingInputHarvester harvester = new SwingInputHarvester(); + harvester.context = context; + return harvester; + } + + /** Sets the title the dialog shows. */ + public void setTitle(final String title) { + this.title = title; + } + + @Override + public boolean harvest(final ParameterModel model) { + final AtomicBoolean accepted = new AtomicBoolean(); + try { + // NB: harvesting happens on whichever thread is running the + // preprocessors, which is not the event dispatch thread; building the + // dialog there and showing it from there would be two bugs at once. + final SwingDialog[] dialog = new SwingDialog[1]; + SwingUtilities.invokeAndWait(() -> dialog[0] = new SwingDialog(null, + title, model, factories())); + SwingUtilities.invokeAndWait(() -> accepted.set(dialog[0] + .showDialog())); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + return false; + } + catch (final InvocationTargetException exc) { + throw new IllegalStateException("Failed to harvest inputs", exc); + } + return accepted.get(); + } + + // -- Helper methods -- + + /** Gets the widgets available, discovering them on first use. */ + private synchronized List> factories() { + if (factories != null) return factories; + final List> found = new ArrayList<>(); + if (context != null) { + for (final Discovery discovery : context.plugins( + WidgetFactory.class)) + { + final WidgetFactory factory = discovery.get(); + // NB: every toolkit's widgets are discovered together, so keep the + // ones this dialog can actually place. + if (!SwingWidget.class.isAssignableFrom(factory.widgetType())) continue; + @SuppressWarnings("unchecked") + final WidgetFactory swing = + (WidgetFactory) factory; + found.add(swing); + } + } + if (found.isEmpty()) { + // NB: the usual cause is a missing annotation index, which javac + // produces only when scijava-index is on the annotation processor path + // -- and says nothing at all when it is not. The dialog would otherwise + // come up empty, with no clue as to why. + log.warn("No Swing widget factories discovered. Is the annotation " + + "index (META-INF/json/org.scijava.context.Plugin) on the classpath?"); + } + else if (log.isDebugEnabled()) { + found.forEach(f -> log.debug("Widget factory: {}", f.getClass() + .getName())); + } + factories = List.copyOf(found); + return factories; + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingMenus.java b/swing/src/main/java/org/scijava/swing/SwingMenus.java new file mode 100644 index 000000000..1b315bf7f --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingMenus.java @@ -0,0 +1,151 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.Toolkit; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.net.URL; +import java.util.Optional; +import java.util.function.Consumer; + +import javax.swing.ImageIcon; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.KeyStroke; + +import org.scijava.command3.Accelerator; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuCreator; +import org.scijava.command3.MenuTree; +import org.scijava.command3.Menus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Builds a Swing menu bar from a {@link MenuTree}. + *

+ * Building one loads no command classes: every label, accelerator and icon + * here came out of the annotation index, and the command behind an item is + * loaded when somebody chooses it. + *

+ * + * @author Curtis Rueden + */ +public class SwingMenus implements MenuCreator { + + private static final Logger log = LoggerFactory.getLogger(SwingMenus.class); + + private final Consumer onSelect; + + /** + * @param onSelect what to do with the command the user chose. It is called + * on the event dispatch thread, so it should hand the work + * elsewhere rather than run it there. + */ + public SwingMenus(final Consumer onSelect) { + this.onSelect = onSelect; + } + + /** Builds a menu bar holding the given menu tree. */ + public static JMenuBar create(final MenuTree root, + final Consumer onSelect) + { + return Menus.build(root, new JMenuBar(), new SwingMenus(onSelect)); + } + + @Override + public JMenu topMenu(final MenuTree node, final JMenuBar bar) { + final JMenu menu = new JMenu(node.label()); + bar.add(menu); + return menu; + } + + @Override + public JMenu subMenu(final MenuTree node, final JMenu parent) { + final JMenu menu = new JMenu(node.label()); + parent.add(menu); + return menu; + } + + @Override + public void item(final MenuTree leaf, final JMenu parent) { + final CommandInfo command = leaf.entry().orElseThrow(); + final JMenuItem item = new JMenuItem(leaf.label()); + command.accelerator().map(SwingMenus::keyStroke).ifPresent( + item::setAccelerator); + command.iconPath().map(this::icon).ifPresent(item::setIcon); + item.addActionListener(e -> onSelect.accept(command)); + parent.add(item); + } + + // -- Helper methods -- + + /** + * Turns an accelerator into a Swing keystroke. + *

+ * A shortcut with no modifiers is handed to + * {@link KeyStroke#getKeyStroke(String)}, so Swing's own notation + * ({@code "control shift N"}) works too. + *

+ * + * @return the keystroke, or null if it cannot be read + */ + static KeyStroke keyStroke(final String accelerator) { + final Optional parsed = Accelerator.parse(accelerator); + if (parsed.isEmpty()) return null; + final Accelerator a = parsed.get(); + if (!a.hasModifiers()) return KeyStroke.getKeyStroke(a.key()); + if (!a.isCharacter()) { + log.warn("Unreadable accelerator: {}", accelerator); + return null; + } + int modifiers = 0; + if (a.isShortcut()) modifiers |= Toolkit.getDefaultToolkit() + .getMenuShortcutKeyMaskEx(); + if (a.isAlt()) modifiers |= InputEvent.ALT_DOWN_MASK; + if (a.isShift()) modifiers |= InputEvent.SHIFT_DOWN_MASK; + return KeyStroke.getKeyStroke(KeyEvent.getExtendedKeyCodeForChar(a.key() + .charAt(0)), modifiers); + } + + private ImageIcon icon(final String path) { + // NB: the icon is a resource of whichever component declared the command, + // so a missing one is that component's problem and not worth failing the + // whole menu over. + final URL url = getClass().getResource(path); + if (url == null) { + log.warn("No such icon: {}", path); + return null; + } + return new ImageIcon(url); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingMessageWidget.java b/swing/src/main/java/org/scijava/swing/SwingMessageWidget.java new file mode 100644 index 000000000..8d73c0b76 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingMessageWidget.java @@ -0,0 +1,77 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import javax.swing.JComponent; +import javax.swing.JLabel; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; + +/** + * A widget that shows text rather than collecting it: a heading, a note, a + * warning that another value has just provoked. + *

+ * The parameter's value is the message, so a callback can rewrite it. + *

+ * + * @author Curtis Rueden + */ +public class SwingMessageWidget extends SwingWidget { + + /** Style hint: show this parameter rather than collecting it. */ + public static final String MESSAGE = "message"; + + private final JLabel label; + + public SwingMessageWidget(final ParameterNode node, + final ParameterModel model) + { + super(node, model); + label = new JLabel(); + refresh(); + } + + @Override + public JComponent component() { + return label; + } + + @Override + public boolean isLabeled() { + return false; // NB: the message is the whole row. + } + + @Override + protected void doRefresh() { + final Object value = value(); + label.setText(value == null ? "" : value.toString()); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java new file mode 100644 index 000000000..20bee68da --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java @@ -0,0 +1,65 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * Makes {@link SwingMessageWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingMessageWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return Widgets.isStyle(node, SwingMessageWidget.MESSAGE); + } + + @Override + public SwingWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new SwingMessageWidget(node, model); + } + + @Override + public double priority() { + // NB: an explicit "show this, do not collect it" outranks the type. + return Priority.VERY_HIGH; + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingNumberWidget.java b/swing/src/main/java/org/scijava/swing/SwingNumberWidget.java new file mode 100644 index 000000000..97690c240 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingNumberWidget.java @@ -0,0 +1,234 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.util.Hashtable; + +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollBar; +import javax.swing.JSlider; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.Widgets; + +/** + * A widget for a number: a spinner, optionally beside a slider or a scroll + * bar. + *

+ * The spinner enforces the parameter's hard {@code min} and {@code max}; the + * slider spans its {@code softMin} and {@code softMax}, which is how a + * parameter whose useful range is far narrower than its permitted one stays + * draggable. + *

+ * + * @author Curtis Rueden + */ +public class SwingNumberWidget extends SwingWidget { + + /** Style hint: put a slider beside the spinner. */ + public static final String SLIDER = "slider"; + + /** Style hint: put a scroll bar beside the spinner. */ + public static final String SCROLL_BAR = "scroll bar"; + + /** Style hint: mark the slider's scale. Takes a value: {@code ticks:10}. */ + public static final String TICKS = "ticks"; + + private final Class type; + private final Number min; + private final Number max; + private final Number softMin; + private final Number softMax; + private final Number step; + + private final JSpinner spinner; + private final JSlider slider; + private final JScrollBar scrollBar; + private final JPanel component; + + private boolean syncing; + + public SwingNumberWidget(final ParameterNode node, + final ParameterModel model) + { + super(node, model); + type = Widgets.box(Widgets.type(node)); + min = Widgets.min(node); + max = Widgets.max(node); + softMin = Widgets.softMin(node); + softMax = Widgets.softMax(node); + step = Widgets.stepSize(node); + + spinner = new JSpinner(spinnerModel()); + final Dimension size = spinner.getPreferredSize(); + size.width = Math.max(size.width, 80); + spinner.setPreferredSize(size); + + component = panel(); + component.setLayout(new BorderLayout(4, 0)); + + final boolean scalable = softMin != null && softMax != null; + slider = scalable && Widgets.isStyle(node, SLIDER) ? slider() : null; + scrollBar = slider == null && scalable && Widgets.isStyle(node, SCROLL_BAR) + ? scrollBar() : null; + final JComponent scale = slider != null ? slider : scrollBar; + if (scale != null) component.add(scale, BorderLayout.CENTER); + component.add(spinner, scale == null ? BorderLayout.CENTER + : BorderLayout.EAST); + + refresh(); + spinner.addChangeListener(e -> { + syncScale(); + update(number(spinner.getValue())); + }); + if (slider != null) slider.addChangeListener(e -> { + if (!syncing) spinner.setValue(number(fromScale(slider.getValue()))); + }); + if (scrollBar != null) scrollBar.addAdjustmentListener(e -> { + if (!syncing) spinner.setValue(number(fromScale(scrollBar.getValue()))); + }); + } + + @Override + public JComponent component() { + return component; + } + + @Override + protected void doRefresh() { + final Number value = number(value()); + if (!value.equals(spinner.getValue())) spinner.setValue(value); + syncScale(); + } + + // -- Helper methods -- + + private SpinnerNumberModel spinnerModel() { + final Number value = number(value()); + // NB: SpinnerNumberModel steps in the type of the value it holds, so + // handing it a value of the parameter's own type is what keeps an int + // parameter integral. + return new SpinnerNumberModel(value, (Comparable) min, + (Comparable) max, step); + } + + private JSlider slider() { + final JSlider s = new JSlider(0, steps()); + s.setOpaque(false); + // NB: the marks come first, because they make the slider taller: asking + // for its preferred height before adding them clips the labels. + Widgets.styleValue(node(), TICKS).ifPresent(count -> label(s, count)); + s.setPreferredSize(new Dimension(160, s.getPreferredSize().height)); + return s; + } + + private JScrollBar scrollBar() { + final JScrollBar s = new JScrollBar(JScrollBar.HORIZONTAL, 0, 1, 0, steps() + + 1); + s.setPreferredSize(new Dimension(160, s.getPreferredSize().height)); + return s; + } + + /** Marks the slider's scale, with the values the marks stand for. */ + private void label(final JSlider s, final String count) { + final int ticks; + try { + ticks = Math.max(1, Integer.parseInt(count)); + } + catch (final NumberFormatException exc) { + return; // NB: an unreadable hint is no reason to lose the slider + } + final int spacing = Math.max(1, steps() / ticks); + s.setMajorTickSpacing(spacing); + s.setPaintTicks(true); + final Hashtable labels = new Hashtable<>(); + for (int i = 0; i <= ticks; i++) { + final int pos = Math.min(steps(), i * spacing); + labels.put(pos, new JLabel(String.valueOf(number(fromScale(pos))))); + } + s.setLabelTable(labels); + s.setPaintLabels(true); + } + + /** Gets how many steps the scale spans, capped at a usable number. */ + private int steps() { + final double span = softMax.doubleValue() - softMin.doubleValue(); + final double stepped = span / step.doubleValue(); + // NB: a double parameter with a tiny step would ask for billions of + // slider positions; the spinner beside it stays exact either way. + return (int) Math.max(1, Math.min(stepped, 10_000)); + } + + private void syncScale() { + if (slider == null && scrollBar == null) return; + syncing = true; + try { + final double value = number(spinner.getValue()).doubleValue(); + final double span = softMax.doubleValue() - softMin.doubleValue(); + final int pos = (int) Math.round((value - softMin.doubleValue()) / span * + steps()); + final int clamped = Math.max(0, Math.min(steps(), pos)); + if (slider != null) slider.setValue(clamped); + if (scrollBar != null) scrollBar.setValue(clamped); + } + finally { + syncing = false; + } + } + + private double fromScale(final int pos) { + final double span = softMax.doubleValue() - softMin.doubleValue(); + return softMin.doubleValue() + span * pos / steps(); + } + + /** Reads a value as the parameter's own numeric type. */ + private Number number(final Object value) { + if (value == null) { + if (min != null) return number(min); + return (Number) Widgets.toType("0", type); + } + final Number n = value instanceof Number ? (Number) value // + : Widgets.toType(String.valueOf(value), type); + if (n == null) return (Number) Widgets.toType("0", type); + if (type == Byte.class) return n.byteValue(); + if (type == Short.class) return n.shortValue(); + if (type == Integer.class) return n.intValue(); + if (type == Long.class) return n.longValue(); + if (type == Float.class) return n.floatValue(); + return n.doubleValue(); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java new file mode 100644 index 000000000..c21a45f52 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java @@ -0,0 +1,65 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * Makes {@link SwingNumberWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingNumberWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + if (!node.choices().isEmpty() || !Widgets.isNumber(node)) return false; + // NB: a JSpinner cannot step arbitrary-precision numbers, so those fall + // through to the text widget, which parses them exactly. + final Class type = Widgets.box(Widgets.type(node)); + return type != BigInteger.class && type != BigDecimal.class; + } + + @Override + public SwingWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new SwingNumberWidget(node, model); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingPanel.java b/swing/src/main/java/org/scijava/swing/SwingPanel.java new file mode 100644 index 000000000..3eac41c69 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingPanel.java @@ -0,0 +1,191 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.Color; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; + +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetPanel; + +/** + * A panel of Swing widgets: the body of a dialog, or one group within it. + *

+ * A group renders as a titled box, and a collapsible one gets a header the + * user can click to fold it away. Since a panel is itself a + * {@link SwingWidget}, a group sits in its parent's list like any other + * widget, however deeply nested. + *

+ * + * @author Curtis Rueden + */ +public class SwingPanel extends SwingWidget implements + WidgetPanel +{ + + private final List widgets; + private final Map labels = new LinkedHashMap<>(); + private final JPanel component; + private final JPanel body; + + private boolean collapsed; + + public SwingPanel(final ParameterNode group, final List widgets, + final org.scijava.harvest.ParameterModel model) + { + super(group, model); + this.widgets = new ArrayList<>(widgets); + + body = new JPanel(new GridBagLayout()); + body.setOpaque(false); + layOut(); + + if (group == null) { + component = body; + return; + } + + component = new JPanel(); + component.setOpaque(false); + component.setLayout(new BoxLayout(component, BoxLayout.Y_AXIS)); + if (group.isCollapsible()) { + collapsed = group.isCollapsed(); + final JButton header = new JButton(); + header.setBorderPainted(false); + header.setContentAreaFilled(false); + header.setFocusPainted(false); + header.setHorizontalAlignment(JButton.LEFT); + header.addActionListener(e -> { + collapsed = !collapsed; + body.setVisible(!collapsed); + header.setText(title(group)); + component.revalidate(); + }); + header.setText(title(group)); + component.add(header); + body.setVisible(!collapsed); + } + else { + component.setBorder(BorderFactory.createTitledBorder(group.label())); + } + component.add(body); + } + + @Override + public List widgets() { + return List.copyOf(widgets); + } + + @Override + public JComponent component() { + return component; + } + + @Override + public boolean isLabeled() { + return false; // NB: a group carries its own title. + } + + @Override + public void showProblems(final Map problems) { + for (final Map.Entry entry : labels.entrySet()) { + final String problem = problems.get(entry.getKey()); + final JLabel label = entry.getValue(); + label.setForeground(problem == null ? null : Color.RED.darker()); + if (problem != null) label.setToolTipText(problem); + } + for (final SwingWidget widget : widgets) { + if (widget instanceof SwingPanel) { + ((SwingPanel) widget).showProblems(problems); + } + } + } + + @Override + protected void doRefresh() { + widgets.forEach(SwingWidget::refresh); + } + + // -- Helper methods -- + + private String title(final ParameterNode group) { + return (collapsed ? "▶ " : "▼ ") + group.label(); + } + + /** Lays the widgets out in two columns: labels right, widgets filling. */ + private void layOut() { + final GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 4, 2, 4); + int row = 0; + for (final SwingWidget widget : widgets) { + c.gridy = row++; + c.fill = GridBagConstraints.HORIZONTAL; + if (widget.isLabeled()) { + final ParameterNode node = widget.node(); + final JLabel label = new JLabel(node.label()); + node.member().map(m -> m.member().description()) // + .filter(d -> d != null && !d.isEmpty()) // + .ifPresent(label::setToolTipText); + node.member().ifPresent(m -> labels.put(m.member().key(), label)); + c.gridx = 0; + c.weightx = 0; + c.anchor = GridBagConstraints.LINE_END; + c.gridwidth = 1; + body.add(label, c); + c.gridx = 1; + c.weightx = 1; + c.anchor = GridBagConstraints.LINE_START; + body.add(widget.component(), c); + } + else { + // NB: an unlabeled widget - a group, a message - takes the row. + c.gridx = 0; + c.weightx = 1; + c.gridwidth = 2; + c.anchor = GridBagConstraints.LINE_START; + body.add(widget.component(), c); + c.gridwidth = 1; + } + } + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingPanelFactory.java b/swing/src/main/java/org/scijava/swing/SwingPanelFactory.java new file mode 100644 index 000000000..ba87f21ba --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingPanelFactory.java @@ -0,0 +1,57 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.util.List; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetPanelFactory; + +/** + * Makes {@link SwingPanel}s. + * + * @author Curtis Rueden + */ +public class SwingPanelFactory implements WidgetPanelFactory { + + private final ParameterModel model; + + public SwingPanelFactory(final ParameterModel model) { + this.model = model; + } + + @Override + public SwingWidget create(final ParameterNode group, + final List widgets) + { + return new SwingPanel(group, widgets, model); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingTextWidget.java b/swing/src/main/java/org/scijava/swing/SwingTextWidget.java new file mode 100644 index 000000000..9c6237654 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingTextWidget.java @@ -0,0 +1,132 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import javax.swing.JComponent; +import javax.swing.JPasswordField; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.JTextComponent; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.Widgets; + +/** + * A widget for text: a field, a password field, or a multi-line area. + * + * @author Curtis Rueden + */ +public class SwingTextWidget extends SwingWidget implements DocumentListener { + + /** Style hint: hide what the user types. */ + public static final String PASSWORD = "password"; + + /** Style hint: give the user several lines. */ + public static final String AREA = "text area"; + + private final JTextComponent text; + private final JComponent component; + + public SwingTextWidget(final ParameterNode node, + final ParameterModel model) + { + super(node, model); + final int columns = Integer.parseInt(Widgets.styleValue(node, "columns") + .orElse("16")); + if (Widgets.isStyle(node, AREA)) { + final JTextArea area = new JTextArea(5, columns); + area.setLineWrap(true); + area.setWrapStyleWord(true); + text = area; + component = new JScrollPane(area); + } + else if (Widgets.isStyle(node, PASSWORD)) { + text = new JPasswordField(columns); + component = text; + } + else { + text = new JTextField(columns); + component = text; + } + refresh(); + text.getDocument().addDocumentListener(this); + } + + @Override + public JComponent component() { + return component; + } + + @Override + public void insertUpdate(final DocumentEvent e) { + changed(); + } + + @Override + public void removeUpdate(final DocumentEvent e) { + changed(); + } + + @Override + public void changedUpdate(final DocumentEvent e) { + changed(); + } + + @Override + protected void doRefresh() { + final Object value = value(); + final String s = value == null ? "" : value.toString(); + if (!s.equals(text.getText())) text.setText(s); + } + + // -- Helper methods -- + + private void changed() { + final String s = text.getText(); + final Class type = Widgets.box(Widgets.type(node())); + if (type == String.class) { + update(s); + } + else if (type == Character.class) { + if (!s.isEmpty()) update(s.charAt(0)); + } + else { + // NB: BigInteger and BigDecimal land here, a spinner being unable to + // step them. Half-typed text is not a value, so an unparseable field + // leaves the parameter alone rather than writing a zero into it. + final Number value = Widgets.toType(s, type); + if (value != null) update(value); + } + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java new file mode 100644 index 000000000..8e2553e8c --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java @@ -0,0 +1,70 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * Makes {@link SwingTextWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingTextWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + if (!node.choices().isEmpty()) return false; + // NB: also the arbitrary-precision numbers, which a spinner cannot step + // and which this widget parses exactly. + final Class type = Widgets.box(Widgets.type(node)); + return Widgets.isText(node) || type == java.math.BigInteger.class || + type == java.math.BigDecimal.class; + } + + @Override + public SwingWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new SwingTextWidget(node, model); + } + + @Override + public double priority() { + // NB: last resort among widgets that recognize a type. + return Priority.LOW; + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingToggleWidget.java b/swing/src/main/java/org/scijava/swing/SwingToggleWidget.java new file mode 100644 index 000000000..47c70bfd1 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingToggleWidget.java @@ -0,0 +1,68 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import javax.swing.JCheckBox; +import javax.swing.JComponent; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; + +/** + * A widget for a true/false value. + * + * @author Curtis Rueden + */ +public class SwingToggleWidget extends SwingWidget { + + private final JCheckBox checkBox; + + public SwingToggleWidget(final ParameterNode node, + final ParameterModel model) + { + super(node, model); + checkBox = new JCheckBox(); + checkBox.setOpaque(false); + refresh(); + checkBox.addItemListener(e -> update(checkBox.isSelected())); + } + + @Override + public JComponent component() { + return checkBox; + } + + @Override + protected void doRefresh() { + final Object value = value(); + final boolean selected = value instanceof Boolean && (Boolean) value; + if (checkBox.isSelected() != selected) checkBox.setSelected(selected); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java new file mode 100644 index 000000000..f5d4e4219 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java @@ -0,0 +1,58 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import org.scijava.context.Plugin; +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanelFactory; +import org.scijava.ui3.Widgets; + +/** + * Makes {@link SwingToggleWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingToggleWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return Widgets.isBoolean(node); + } + + @Override + public SwingWidget create(final ParameterNode node, + final ParameterModel model, final WidgetPanelFactory panels) + { + return new SwingToggleWidget(node, model); + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingWidget.java b/swing/src/main/java/org/scijava/swing/SwingWidget.java new file mode 100644 index 000000000..8574d7962 --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingWidget.java @@ -0,0 +1,59 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import javax.swing.JComponent; +import javax.swing.JPanel; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.AbstractWidget; + +/** + * A {@link org.scijava.ui3.Widget} made of Swing components. + * + * @author Curtis Rueden + */ +public abstract class SwingWidget extends AbstractWidget { + + protected SwingWidget(final ParameterNode node, final ParameterModel model) { + super(node, model); + } + + /** Gets the component to place in the dialog. */ + public abstract JComponent component(); + + /** Makes a panel with no padding, for a widget of several components. */ + protected static JPanel panel() { + final JPanel panel = new JPanel(); + panel.setOpaque(false); + return panel; + } +} diff --git a/swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java new file mode 100644 index 000000000..ddcc4a13e --- /dev/null +++ b/swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java @@ -0,0 +1,58 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import org.scijava.ui3.WidgetFactory; + +/** + * A {@link WidgetFactory} that makes Swing widgets. + *

+ * Contribute one as a plugin: + *

+ * + *
+ * @Plugin(type = WidgetFactory.class)
+ * public class MyWidgetFactory implements SwingWidgetFactory { ... }
+ * 
+ *

+ * The plugin type is {@link WidgetFactory}, not this interface, so that every + * toolkit's widgets are discovered the same way; a Swing dialog then keeps the + * ones whose {@link #widgetType()} it can use. + *

+ * + * @author Curtis Rueden + */ +public interface SwingWidgetFactory extends WidgetFactory { + + @Override + default Class widgetType() { + return SwingWidget.class; + } +} diff --git a/swing/src/test/java/org/scijava/swing/Demos.java b/swing/src/test/java/org/scijava/swing/Demos.java new file mode 100644 index 000000000..aa09d91aa --- /dev/null +++ b/swing/src/test/java/org/scijava/swing/Demos.java @@ -0,0 +1,179 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.GridLayout; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Future; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; + +import org.scijava.context.Access; +import org.scijava.context.Context; +import org.scijava.execute.Executables; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Runner; +import org.scijava.ui3.test.SampleCommands; + +/** + * Dialogs to click on. + *

+ * Run it with: + *

+ * + *
+ * mvn -pl scijava-swing test-compile exec:java \
+ *   -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.swing.Demos
+ * 
+ *

+ * A name on the command line runs that demo straight away; with no name, a + * window lists them. Each is a behavior that is hard to get right, so that + * what the dialog does can be seen rather than reasoned about. + *

+ *

+ * The commands themselves are in {@link SampleCommands}, shared with every + * other toolkit binding, so that what Swing shows and what JavaFX shows are + * the same declarations. + *

+ * + * @author Curtis Rueden + */ +public final class Demos { + + private Demos() { + // prevent instantiation of utility class + } + + // -- Launcher -- + + private static final Map> DEMOS = + new LinkedHashMap<>(); + + static { + DEMOS.put("Kitchen sink", SampleCommands.KitchenSink.class); + DEMOS.put("Advanced toggle", SampleCommands.AdvancedToggle.class); + DEMOS.put("Generated group", SampleCommands.GeneratedGroup.class); + DEMOS.put("Computed choices", SampleCommands.ComputedChoices.class); + DEMOS.put("Live validation", SampleCommands.LiveValidation.class); + DEMOS.put("Linked values", SampleCommands.LinkedValues.class); + DEMOS.put("Lock aspect ratio", SampleCommands.LockAspectRatio.class); + } + + public static void main(final String... args) { + final Context context = Context.create(); + if (args.length > 0) { + final String wanted = String.join(" ", args); + final Class demo = DEMOS.entrySet().stream() // + .filter(e -> e.getKey().equalsIgnoreCase(wanted)) // + .map(Map.Entry::getValue).findFirst().orElse(null); + if (demo == null) { + System.err.println("No such demo: " + wanted); + System.err.println("Try one of: " + String.join(", ", DEMOS.keySet())); + System.exit(1); + } + run(context, demo, true); + return; + } + SwingUtilities.invokeLater(() -> launcher(context)); + } + + // -- Helper methods -- + + private static void launcher(final Context context) { + final JFrame frame = new JFrame("SciJava Swing demos"); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + final JPanel panel = new JPanel(new GridLayout(0, 1, 4, 4)); + panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + for (final Map.Entry> demo : DEMOS + .entrySet()) + { + final JButton button = new JButton(demo.getKey()); + button.addActionListener(e -> run(context, demo.getValue(), false)); + panel.add(button); + } + frame.setContentPane(panel); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + } + + /** Runs one demo, reporting what came back. */ + private static void run(final Context context, + final Class type, final boolean exitWhenDone) + { + final SwingInputHarvester harvester = SwingInputHarvester.of(context); + harvester.setTitle(type.getSimpleName()); + final Runner runner = Runner.of(List.of(harvester), List.of()); + final Runnable command; + try { + command = type.getDeclaredConstructor().newInstance(); + } + catch (final ReflectiveOperationException exc) { + throw new IllegalStateException(exc); + } + // NB: reflect through a lookup the container supplies, so that a demo + // needs no `opens` beyond the one this module already declares. + final Future future = runner.run(Executables.executableOf( + command, Access.lookupIn(type)), Map.of()); + new Thread(() -> { + final String message = describe(future); + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog(null, message, type.getSimpleName(), + JOptionPane.INFORMATION_MESSAGE); + if (exitWhenDone) System.exit(0); + }); + }, "demo-result").start(); + } + + private static String describe(final Future future) { + final ExecutionResult result; + try { + result = future.get(); + } + catch (final Exception exc) { + return "Failed: " + exc; + } + if (result.isDeclined()) { + return "Declined: " + result.reason().orElse("no reason given"); + } + final List lines = new ArrayList<>(); + result.outputs().forEach((key, value) -> lines.add(key + " = " + value)); + return lines.isEmpty() ? "Completed with no outputs" : String.join("\n", + lines); + } +} diff --git a/swing/src/test/java/org/scijava/swing/ScriptMenuTest.java b/swing/src/test/java/org/scijava/swing/ScriptMenuTest.java new file mode 100644 index 000000000..2ab96ba62 --- /dev/null +++ b/swing/src/test/java/org/scijava/swing/ScriptMenuTest.java @@ -0,0 +1,149 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.GraphicsEnvironment; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; +import org.scijava.script3.ScriptFinder; + +/** + * Tests that scripts reach the menus the same way commands do. + *

+ * This is what a menu built from several sources looks like: an annotated Java + * class and a Groovy file in a directory arrive as the same kind of thing, and + * the menu cannot tell them apart. + *

+ * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class ScriptMenuTest { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + /** A script's declared menu path puts it where it asked to be. */ + @Test + public void testDeclaredMenuPath() { + final JMenuBar bar = menuBar(); + final JMenuItem item = item(menu(bar, "Help"), "Say Hello From A Script"); + assertNotNull(item, "the script declared this menu path itself"); + assertNotNull(item.getAccelerator(), "and this accelerator"); + } + + /** A script that declares nothing is placed by the directory it sits in. */ + @Test + public void testDirectoryDerivedMenuPath() { + final JMenu filters = (JMenu) item(menu(menuBar(), "Process"), "Filters"); + final List labels = new ArrayList<>(); + for (int i = 0; i < filters.getItemCount(); i++) + labels.add(filters.getItem(i).getText()); + + // NB: the Java command and the script, side by side, in weight order. + assertTrue(labels.contains("Gaussian Blur..."), labels.toString()); + assertTrue(labels.contains("Blur It"), labels.toString()); + } + + /** What the menu holds is the thing to run, script or command alike. */ + @Test + public void testScriptRunsFromItsMenuEntry() { + final CommandInfo hello = MenuTree.of(entries()) // + .find("Help>Say Hello From A Script").orElseThrow() // + .entry().orElseThrow(); + + final var instance = hello.create(); + instance.run(); + + assertEquals("hello world", instance.parameters().member("greeting") + .get()); + } + + // -- Helper methods -- + + private static List entries() { + final List entries = new ArrayList<>(); + try (final org.scijava.context.Context context = // + org.scijava.context.Context.create()) + { + org.scijava.command3.Commands.discover(context).stream() // + .filter(c -> c.className().contains("SampleCommands")) // + .forEach(entries::add); + } + new ScriptFinder().find(scripts()).forEach(found -> entries.add(CommandInfo + .of(found.script(), found.metadata()))); + return entries; + } + + private static Path scripts() { + try { + return Paths.get(ScriptMenuTest.class.getResource("/scripts").toURI()); + } + catch (final URISyntaxException exc) { + throw new IllegalStateException(exc); + } + } + + private static JMenuBar menuBar() { + return SwingMenus.create(MenuTree.of(entries()), entry -> {}); + } + + private static JMenu menu(final JMenuBar bar, final String label) { + for (int i = 0; i < bar.getMenuCount(); i++) { + if (bar.getMenu(i).getText().equals(label)) return bar.getMenu(i); + } + throw new AssertionError("No such menu: " + label); + } + + private static JMenuItem item(final JMenu menu, final String label) { + for (int i = 0; i < menu.getItemCount(); i++) { + if (menu.getItem(i).getText().equals(label)) return menu.getItem(i); + } + throw new AssertionError("No such item: " + label + " in " + menu + .getText()); + } +} diff --git a/swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java b/swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java new file mode 100644 index 000000000..628b62c60 --- /dev/null +++ b/swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java @@ -0,0 +1,191 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.Component; +import java.awt.Container; +import java.awt.GraphicsEnvironment; +import java.util.List; + +import javax.swing.JSpinner; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.harvest.ParameterModel; +import org.scijava.script3.ScriptExecutable; +import org.scijava.script3.Scripts; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanels; + +/** + * Tests that a script gets the same dialog a Java command gets. + *

+ * This is the claim the whole stack rests on: the widgets know about + * parameters, not about where the parameters came from. A script declares + * them in its header rather than in annotated fields, and everything above + * behaves identically - including callbacks, which SciJava Common could not + * do for scripts at all. + *

+ * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class ScriptWidgetsTest { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + private static final List> FACTORIES = List.of( // + new SwingMessageWidgetFactory(), // + new SwingChoiceWidgetFactory(), // + new SwingNumberWidgetFactory(), // + new SwingToggleWidgetFactory(), // + new SwingFileWidgetFactory(), // + new SwingTextWidgetFactory()); + + /** A script's declared parameters get the widgets their types imply. */ + @Test + public void testWidgetsForAScript() { + final SwingPanel panel = panel(script("" + // + "#@ String name\n" + // + "#@ int(min = 0, max = 100, style = \"slider\") percent\n" + // + "#@ boolean enabled\n" + // + "#@ String(choices = \"Mean,Median,Max\") statistic\n" + // + "#@ File input\n" + // + "#@output String summary\n")); + + assertEquals(List.of( // + SwingTextWidget.class, // + SwingNumberWidget.class, // + SwingToggleWidget.class, // + SwingChoiceWidget.class, // + SwingFileWidget.class), // + panel.widgets().stream().map(Object::getClass).collect(java.util.stream + .Collectors.toList())); + } + + /** A script's label and bounds reach the widget, as a command's would. */ + @Test + public void testScriptMetadata() { + final SwingPanel panel = panel(script( // + "#@ double(label = \"Blur radius\", min = 0, max = 20) sigma\n")); + + assertEquals("Blur radius", panel.widgets().get(0).node().label()); + } + + /** A script's callback runs when a widget writes through the model. */ + @Test + public void testScriptCallbackFromAWidget() { + final ParameterModel model = model(script("" + // + "#@ double(callback = \"celsiusChanged\") celsius\n" + // + "#@ double fahrenheit\n" + // + "def celsiusChanged() {\n" + // + " fahrenheit = celsius * 9 / 5 + 32\n" + // + "}\n")); + final SwingPanel panel = (SwingPanel) new WidgetPanels<>(FACTORIES, + new SwingPanelFactory(model)).build(model); + final SwingNumberWidget celsius = assertInstanceOf(SwingNumberWidget.class, + panel.widgets().get(0)); + + // NB: through the actual control, which is the path a user takes. + spinner(celsius.component()).setValue(100.0); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** A script may compute its own choices, as a command may. */ + @Test + public void testScriptComputedChoices() { + final ParameterModel model = model(script("" + // + "#@ String(choices = \"stack.tif,table.csv\", value = \"stack.tif\") file\n" + // + "#@ String(choicesFrom = \"columns\") column\n" + // + "def columns() {\n" + // + " file.endsWith(\".tif\") ? [\"X\", \"Y\", \"Z\"] : [\"id\", \"area\"]\n" + // + "}\n")); + + assertEquals(List.of("X", "Y", "Z"), model.tree().find("column") + .orElseThrow().choices()); + + model.set("file", "table.csv"); + + assertEquals(List.of("id", "area"), model.tree().find("column") + .orElseThrow().choices()); + } + + /** A script's validator reports problems the same way. */ + @Test + public void testScriptValidation() { + final ParameterModel model = model(script("" + // + "#@ int(validator = \"checkCount\") count\n" + // + "def checkCount(value) {\n" + // + " value > 0 && value <= 10 ? null : \"Count must be between 1 and 10\"\n" + // + "}\n")); + + model.set("count", 5); + assertTrue(model.problems().isEmpty()); + + model.set("count", 42); + assertEquals("Count must be between 1 and 10", model.problems().get( + "count")); + } + + // -- Helper methods -- + + /** Finds the spinner inside a widget's components. */ + private static JSpinner spinner(final Container container) { + for (final Component child : container.getComponents()) { + if (child instanceof JSpinner) return (JSpinner) child; + if (child instanceof Container) { + final JSpinner found = spinner((Container) child); + if (found != null) return found; + } + } + return null; + } + + private static ScriptExecutable script(final String code) { + return Scripts.get().of("test.groovy", code, "groovy"); + } + + private static ParameterModel model(final ScriptExecutable script) { + return new ParameterModel(script.create()); + } + + private static SwingPanel panel(final ScriptExecutable script) { + final ParameterModel model = model(script); + return (SwingPanel) new WidgetPanels<>(FACTORIES, new SwingPanelFactory( + model)).build(model); + } +} diff --git a/swing/src/test/java/org/scijava/swing/Shell.java b/swing/src/test/java/org/scijava/swing/Shell.java new file mode 100644 index 000000000..38f37b0d6 --- /dev/null +++ b/swing/src/test/java/org/scijava/swing/Shell.java @@ -0,0 +1,278 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.event.KeyEvent; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Future; +import java.util.stream.Collectors; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListModel; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JProgressBar; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import org.scijava.command3.Commands; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; +import org.scijava.context.Context; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Runner; +import org.scijava.script3.ScriptFinder; + +/** + * A small application: menus, a search bar, a status line. + *

+ * Run it with: + *

+ * + *
+ * mvn -o -pl scijava-swing test-compile exec:java -Denforcer.skip \
+ *   -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.swing.Shell
+ * 
+ *

+ * It is the first thing to put the whole stack together: commands declared in + * {@code SampleCommands} and found through the annotation index - and Groovy + * scripts found in a directory, which arrive in the same menus through the + * same {@code CommandInfo} - arranged by + * {@link MenuTree}, rendered by {@link SwingMenus}, run by + * {@link org.scijava.execute.Runner} and filled in by + * {@link SwingInputHarvester}. Starting it loads no command class; choosing + * one loads that one. + *

+ * + * @author Curtis Rueden + */ +public class Shell { + + private final Context context = Context.create(); + private final List commands = entries(); + private final Runner runner; + + private final JFrame frame = new JFrame("SciJava"); + private final JLabel status = new JLabel("Ready"); + private final JProgressBar progress = new JProgressBar(); + private final JTextField search = new JTextField(20); + private final DefaultListModel matchModel = + new DefaultListModel<>(); + private final JList matches = new JList<>(matchModel); + + public Shell() { + final SwingInputHarvester harvester = SwingInputHarvester.of(context); + runner = Runner.of(List.of(harvester), List.of()); + + frame.setJMenuBar(SwingMenus.create(MenuTree.of(commands), this::run)); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setContentPane(content()); + frame.setPreferredSize(new Dimension(520, 260)); + frame.pack(); + frame.setLocationRelativeTo(null); + } + + public static void main(final String... args) { + SwingUtilities.invokeLater(() -> new Shell().frame.setVisible(true)); + } + + // -- Helper methods -- + + /** + * Gathers what this application can run, from every source it has. + *

+ * NB: this is the shape the legacy bridge wants too. A menu is built from + * {@code CommandInfo}s, and where each came from - the annotation index, a + * directory of scripts, in time a SciJava Common {@code ModuleInfo} - is + * the application's business and nobody else's. + *

+ */ + private List entries() { + final List entries = new ArrayList<>(Commands.discover( + context)); + scriptsDirectory().ifPresent(dir -> new ScriptFinder().find(dir).forEach( // + found -> entries.add(CommandInfo.of(found.script(), found.metadata())))); + return entries; + } + + /** Finds the demo scripts, wherever the test resources landed. */ + private static Optional scriptsDirectory() { + final URL url = Shell.class.getResource("/scripts"); + if (url == null) return Optional.empty(); + try { + return Optional.of(Paths.get(url.toURI())); + } + catch (final URISyntaxException exc) { + return Optional.empty(); + } + } + + private JPanel content() { + matches.setVisibleRowCount(6); + matches.setCellRenderer((list, value, index, selected, focused) -> { + final JLabel label = new JLabel(describe(value)); + label.setOpaque(true); + if (selected) { + label.setBackground(list.getSelectionBackground()); + label.setForeground(list.getSelectionForeground()); + } + return label; + }); + + search.getDocument().addDocumentListener(new DocumentListener() { + + @Override + public void insertUpdate(final DocumentEvent e) { + filter(); + } + + @Override + public void removeUpdate(final DocumentEvent e) { + filter(); + } + + @Override + public void changedUpdate(final DocumentEvent e) { + filter(); + } + }); + // NB: down from the field into the list, Enter to run - the whole point + // being that a command need not be in a menu to be reachable. + search.addActionListener(e -> runSelected()); + search.addKeyListener(new java.awt.event.KeyAdapter() { + + @Override + public void keyPressed(final KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_DOWN && !matchModel.isEmpty()) { + matches.setSelectedIndex(Math.min(matches.getSelectedIndex() + 1, + matchModel.size() - 1)); + } + else if (e.getKeyCode() == KeyEvent.VK_UP) { + matches.setSelectedIndex(Math.max(matches.getSelectedIndex() - 1, 0)); + } + } + }); + matches.addMouseListener(new java.awt.event.MouseAdapter() { + + @Override + public void mouseClicked(final java.awt.event.MouseEvent e) { + if (e.getClickCount() == 2) runSelected(); + } + }); + filter(); + + final JPanel searchPane = new JPanel(new BorderLayout(4, 4)); + searchPane.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + searchPane.add(new JLabel("Search:"), BorderLayout.WEST); + searchPane.add(search, BorderLayout.CENTER); + + final JPanel south = new JPanel(new BorderLayout()); + progress.setVisible(false); + south.add(progress, BorderLayout.NORTH); + status.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8)); + south.add(status, BorderLayout.SOUTH); + + final JPanel content = new JPanel(new BorderLayout()); + content.add(searchPane, BorderLayout.NORTH); + content.add(new JScrollPane(matches), BorderLayout.CENTER); + content.add(south, BorderLayout.SOUTH); + return content; + } + + /** Narrows the list to the commands whose name contains what was typed. */ + private void filter() { + final String text = search.getText().trim().toLowerCase(Locale.ROOT); + final List found = commands.stream() // + .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // + .collect(Collectors.toList()); + matchModel.clear(); + found.forEach(matchModel::addElement); + if (!matchModel.isEmpty()) matches.setSelectedIndex(0); + status.setText(found.size() + " of " + commands.size() + " commands"); + } + + private void runSelected() { + final CommandInfo command = matches.getSelectedValue(); + if (command != null) run(command); + } + + /** Runs a command, and says what came back. */ + private void run(final CommandInfo command) { + status.setText("Running " + command.label() + "..."); + progress.setIndeterminate(true); + progress.setVisible(true); + final Future future = runner.run(command, Map.of()); + new Thread(() -> { + final String message = describe(future); + SwingUtilities.invokeLater(() -> { + progress.setVisible(false); + progress.setIndeterminate(false); + status.setText(message); + }); + }, "shell-" + command.name()).start(); + } + + private static String describe(final CommandInfo command) { + return command.menuPath().map(path -> path.replace(">", " ▸ ")) // + .orElseGet(() -> command.label() + " (not in any menu)"); + } + + private static String describe(final Future future) { + final ExecutionResult result; + try { + result = future.get(); + } + catch (final Exception exc) { + return "Failed: " + exc.getMessage(); + } + if (result.isDeclined()) { + return result.reason().orElse("Declined"); + } + if (result.outputs().isEmpty()) return "Done"; + return result.outputs().entrySet().stream() // + .map(e -> String.valueOf(e.getValue())) // + .collect(Collectors.joining("; ")); + } +} diff --git a/swing/src/test/java/org/scijava/swing/SwingMenusTest.java b/swing/src/test/java/org/scijava/swing/SwingMenusTest.java new file mode 100644 index 000000000..391fe107c --- /dev/null +++ b/swing/src/test/java/org/scijava/swing/SwingMenusTest.java @@ -0,0 +1,125 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.awt.GraphicsEnvironment; +import java.awt.Toolkit; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.List; + +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.KeyStroke; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.command3.MenuTree; +import org.scijava.ui3.test.MenuConformance; + +/** + * Runs the shared menu conformance suite against the Swing menus, and adds + * what is Swing's own. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class SwingMenusTest extends MenuConformance { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + @Override + protected JMenuBar menuBar(final MenuTree root) { + return SwingMenus.create(root, command -> {}); + } + + @Override + protected List topMenus(final JMenuBar bar) { + final List labels = new ArrayList<>(); + for (int i = 0; i < bar.getMenuCount(); i++) + labels.add(bar.getMenu(i).getText()); + return labels; + } + + @Override + protected List items(final JMenuBar bar, final String... path) { + JMenu menu = menu(bar, path[0]); + for (int i = 1; i < path.length; i++) + menu = (JMenu) item(menu, path[i]); + final List labels = new ArrayList<>(); + for (int i = 0; i < menu.getItemCount(); i++) + labels.add(menu.getItem(i).getText()); + return labels; + } + + /** Accelerators reach the items, in ImageJ's notation and in Swing's. */ + @Test + public void testAccelerators() { + final int shortcut = Toolkit.getDefaultToolkit() + .getMenuShortcutKeyMaskEx(); + assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_O, shortcut), SwingMenus + .keyStroke("^O")); + assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_C, shortcut | + InputEvent.SHIFT_DOWN_MASK), SwingMenus.keyStroke("^+C")); + assertEquals(KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.ALT_DOWN_MASK), + SwingMenus.keyStroke("!N")); + assertEquals(KeyStroke.getKeyStroke("control shift N"), SwingMenus.keyStroke( + "control shift N")); + assertNull(SwingMenus.keyStroke("")); + + final JMenuItem open = (JMenuItem) item(menu(menuBar(), "File"), + "Open..."); + assertNotNull(open.getAccelerator()); + } + + // -- Helper methods -- + + private static JMenu menu(final JMenuBar bar, final String label) { + for (int i = 0; i < bar.getMenuCount(); i++) { + if (bar.getMenu(i).getText().equals(label)) return bar.getMenu(i); + } + throw new AssertionError("No such menu: " + label); + } + + private static JMenuItem item(final JMenu menu, final String label) { + for (int i = 0; i < menu.getItemCount(); i++) { + if (menu.getItem(i).getText().equals(label)) return menu.getItem(i); + } + throw new AssertionError("No such item: " + label); + } +} diff --git a/swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java b/swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java new file mode 100644 index 000000000..0b2bada49 --- /dev/null +++ b/swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java @@ -0,0 +1,134 @@ +/* + * #%L + * Swing widgets, and a dialog to harvest inputs with them. + * %% + * Copyright (C) 2026 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.swing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import java.awt.Component; +import java.awt.Container; +import java.awt.GraphicsEnvironment; +import java.util.List; + +import javax.swing.JSpinner; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanel; +import org.scijava.ui3.test.SampleCommands; +import org.scijava.ui3.test.WidgetConformance; + +/** + * Runs the shared conformance suite against the Swing widgets, and adds what + * is Swing's own. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class SwingWidgetsTest extends WidgetConformance { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + private static final List> FACTORIES = List.of( // + new SwingMessageWidgetFactory(), // + new SwingChoiceWidgetFactory(), // + new SwingNumberWidgetFactory(), // + new SwingToggleWidgetFactory(), // + new SwingFileWidgetFactory(), // + new SwingTextWidgetFactory()); + + @Override + protected WidgetPanel panel(final ParameterModel model) { + return build(model, FACTORIES, new SwingPanelFactory(model)); + } + + @Override + protected Class widgetType(final Kind kind) { + switch (kind) { + case MESSAGE: + return SwingMessageWidget.class; + case TEXT: + return SwingTextWidget.class; + case NUMBER: + return SwingNumberWidget.class; + case TOGGLE: + return SwingToggleWidget.class; + case CHOICE: + return SwingChoiceWidget.class; + case FILE: + return SwingFileWidget.class; + case PANEL: + return SwingPanel.class; + default: + throw new IllegalArgumentException(String.valueOf(kind)); + } + } + + /** + * Driving the actual control runs the callback, which is the path a user + * takes and the one a mocked-out test would miss. + */ + @Test + public void testTypingInTheControlRunsCallback() { + final ParameterModel model = model(new SampleCommands.LinkedValues()); + final SwingPanel panel = (SwingPanel) panel(model); + final SwingNumberWidget celsius = assertInstanceOf(SwingNumberWidget.class, + panel.widgets().get(0)); + + spinner(celsius.component()).setValue(100.0); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** Finds the spinner inside a widget's components. */ + private static JSpinner spinner(final Container container) { + for (final Component child : container.getComponents()) { + if (child instanceof JSpinner) return (JSpinner) child; + if (child instanceof Container) { + final JSpinner found = spinner((Container) child); + if (found != null) return found; + } + } + return null; + } + + /** The panel puts a label beside a widget that wants one, and not else. */ + @Test + public void testLabeling() { + final SwingPanel panel = (SwingPanel) panel(model( + new SampleCommands.KitchenSink())); + assertEquals(false, panel.widgets().get(0).isLabeled()); // the message + assertEquals(true, panel.widgets().get(1).isLabeled()); // the name + } +} diff --git a/swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy @@ -0,0 +1,3 @@ +#@ double(label = "Radius", min = 0, max = 20, style = "slider,ticks:4") sigma +#@output String result +result = "Blurred with sigma " + sigma diff --git a/swing/src/test/resources/scripts/Say_Hello.groovy b/swing/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/swing/src/test/resources/scripts/Say_Hello.groovy @@ -0,0 +1,4 @@ +#@script(menu = "Help>Say Hello From A Script", accelerator = "^H", weight = 50) +#@ String(value = "world") name +#@output String greeting +greeting = "hello " + name diff --git a/scijava-taglets/LICENSE.txt b/taglets/LICENSE.txt similarity index 100% rename from scijava-taglets/LICENSE.txt rename to taglets/LICENSE.txt diff --git a/scijava-taglets/README.md b/taglets/README.md similarity index 100% rename from scijava-taglets/README.md rename to taglets/README.md diff --git a/scijava-taglets/pom.xml b/taglets/pom.xml similarity index 100% rename from scijava-taglets/pom.xml rename to taglets/pom.xml diff --git a/scijava-taglets/src/main/java/org/scijava/taglets/ContainerTaglet.java b/taglets/src/main/java/org/scijava/taglets/ContainerTaglet.java similarity index 100% rename from scijava-taglets/src/main/java/org/scijava/taglets/ContainerTaglet.java rename to taglets/src/main/java/org/scijava/taglets/ContainerTaglet.java diff --git a/scijava-taglets/src/main/java/org/scijava/taglets/InputTaglet.java b/taglets/src/main/java/org/scijava/taglets/InputTaglet.java similarity index 100% rename from scijava-taglets/src/main/java/org/scijava/taglets/InputTaglet.java rename to taglets/src/main/java/org/scijava/taglets/InputTaglet.java diff --git a/scijava-taglets/src/main/java/org/scijava/taglets/MutableTaglet.java b/taglets/src/main/java/org/scijava/taglets/MutableTaglet.java similarity index 100% rename from scijava-taglets/src/main/java/org/scijava/taglets/MutableTaglet.java rename to taglets/src/main/java/org/scijava/taglets/MutableTaglet.java diff --git a/scijava-taglets/src/main/java/org/scijava/taglets/OpDependencyTaglet.java b/taglets/src/main/java/org/scijava/taglets/OpDependencyTaglet.java similarity index 100% rename from scijava-taglets/src/main/java/org/scijava/taglets/OpDependencyTaglet.java rename to taglets/src/main/java/org/scijava/taglets/OpDependencyTaglet.java diff --git a/scijava-taglets/src/main/java/org/scijava/taglets/OutputTaglet.java b/taglets/src/main/java/org/scijava/taglets/OutputTaglet.java similarity index 100% rename from scijava-taglets/src/main/java/org/scijava/taglets/OutputTaglet.java rename to taglets/src/main/java/org/scijava/taglets/OutputTaglet.java diff --git a/scijava-taglets/src/main/java/org/scijava/taglets/ParamTaglet.java b/taglets/src/main/java/org/scijava/taglets/ParamTaglet.java similarity index 100% rename from scijava-taglets/src/main/java/org/scijava/taglets/ParamTaglet.java rename to taglets/src/main/java/org/scijava/taglets/ParamTaglet.java diff --git a/scijava-testutil/LICENSE.txt b/testutil/LICENSE.txt similarity index 100% rename from scijava-testutil/LICENSE.txt rename to testutil/LICENSE.txt diff --git a/scijava-testutil/README.md b/testutil/README.md similarity index 87% rename from scijava-testutil/README.md rename to testutil/README.md index a5a7a03a5..5dfd6a64a 100644 --- a/scijava-testutil/README.md +++ b/testutil/README.md @@ -5,7 +5,7 @@ This library provides a suite of utilities commonly used in SciJava projects. Fo ```java org.scijava - scijava-test-utils + scijava-testutil 1.0.0 test diff --git a/scijava-testutil/pom.xml b/testutil/pom.xml similarity index 100% rename from scijava-testutil/pom.xml rename to testutil/pom.xml diff --git a/scijava-testutil/src/main/java/org/scijava/testutil/ArrayIO.java b/testutil/src/main/java/org/scijava/testutil/ArrayIO.java similarity index 100% rename from scijava-testutil/src/main/java/org/scijava/testutil/ArrayIO.java rename to testutil/src/main/java/org/scijava/testutil/ArrayIO.java diff --git a/scijava-testutil/src/main/java/org/scijava/testutil/AssertClose.java b/testutil/src/main/java/org/scijava/testutil/AssertClose.java similarity index 100% rename from scijava-testutil/src/main/java/org/scijava/testutil/AssertClose.java rename to testutil/src/main/java/org/scijava/testutil/AssertClose.java diff --git a/scijava-testutil/src/main/java/org/scijava/testutil/ExampleTypes.java b/testutil/src/main/java/org/scijava/testutil/ExampleTypes.java similarity index 100% rename from scijava-testutil/src/main/java/org/scijava/testutil/ExampleTypes.java rename to testutil/src/main/java/org/scijava/testutil/ExampleTypes.java diff --git a/scijava-types/LICENSE.txt b/types/LICENSE.txt similarity index 100% rename from scijava-types/LICENSE.txt rename to types/LICENSE.txt diff --git a/scijava-types/NOTICE.txt b/types/NOTICE.txt similarity index 100% rename from scijava-types/NOTICE.txt rename to types/NOTICE.txt diff --git a/scijava-types/README.md b/types/README.md similarity index 69% rename from scijava-types/README.md rename to types/README.md index 5ecc0b089..ad636a6c3 100644 --- a/scijava-types/README.md +++ b/types/README.md @@ -2,9 +2,9 @@ This library provides a suite of utilities for working with generic types in Java. -## `TypeService` and `TypeExtractor`s: an extensible system for discovering generic types +## `TypeReifier` and `TypeExtractor`s: an extensible system for discovering generic types -`TypeService.reify()` offers provides a powerful, extensible way to determine the generic type for, or "reify", a given `Object`. Under the hood, it relies on `TypeExtractor` implementations, allowing the user to extend its capabilities by defining new implementations. +`TypeReifier.reify()` provides a powerful, extensible way to determine the generic type for, or "reify", a given `Object`. Under the hood, it relies on `TypeExtractor` implementations, allowing the user to extend its capabilities by defining new implementations. Also of note is the interface `GenericTyped`, which can be implemented to quickly make any type reifiable. diff --git a/scijava-types/pom.xml b/types/pom.xml similarity index 95% rename from scijava-types/pom.xml rename to types/pom.xml index e2ef67513..e9ba8400a 100644 --- a/scijava-types/pom.xml +++ b/types/pom.xml @@ -97,6 +97,7 @@ 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT @@ -116,6 +117,11 @@ scijava-priority ${scijava-priority.version} + + org.scijava + scijava-spi + ${scijava-spi.version} + diff --git a/scijava-types/src/main/java/module-info.java b/types/src/main/java/module-info.java similarity index 98% rename from scijava-types/src/main/java/module-info.java rename to types/src/main/java/module-info.java index 31f9a0b9f..0f7da923d 100644 --- a/scijava-types/src/main/java/module-info.java +++ b/types/src/main/java/module-info.java @@ -37,6 +37,7 @@ exports org.scijava.types.extract; requires transitive org.scijava.common3; + requires transitive org.scijava.spi; requires transitive org.scijava.discovery; requires org.scijava.priority; diff --git a/scijava-types/src/main/java/org/scijava/types/Nil.java b/types/src/main/java/org/scijava/types/Nil.java similarity index 99% rename from scijava-types/src/main/java/org/scijava/types/Nil.java rename to types/src/main/java/org/scijava/types/Nil.java index 25f242532..dd9c560e5 100644 --- a/scijava-types/src/main/java/org/scijava/types/Nil.java +++ b/types/src/main/java/org/scijava/types/Nil.java @@ -40,7 +40,7 @@ import java.util.Set; import org.scijava.common3.Classes; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; /** * A "typed null" which knows its generic type, and can generate proxy objects diff --git a/scijava-types/src/main/java/org/scijava/types/Proxyable.java b/types/src/main/java/org/scijava/types/Proxyable.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/Proxyable.java rename to types/src/main/java/org/scijava/types/Proxyable.java diff --git a/scijava-types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java b/types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java similarity index 97% rename from scijava-types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java rename to types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java index 271fea6c2..979e1587b 100644 --- a/scijava-types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java +++ b/types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java @@ -70,7 +70,7 @@ private synchronized void initExtractors() { private Map, TypeExtractor> getInstances() { Map, TypeExtractor> instances = new HashMap<>(); discoverers.stream() // - .flatMap(d -> d.discover(TypeExtractor.class).stream()) // + .flatMap(d -> d.instances(TypeExtractor.class).stream()) // // Find the correct set for each extractor and add it. .sorted() // .forEach(e -> { diff --git a/scijava-types/src/main/java/org/scijava/types/extract/IterableTypeExtractor.java b/types/src/main/java/org/scijava/types/extract/IterableTypeExtractor.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/extract/IterableTypeExtractor.java rename to types/src/main/java/org/scijava/types/extract/IterableTypeExtractor.java diff --git a/scijava-types/src/main/java/org/scijava/types/extract/MapTypeExtractor.java b/types/src/main/java/org/scijava/types/extract/MapTypeExtractor.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/extract/MapTypeExtractor.java rename to types/src/main/java/org/scijava/types/extract/MapTypeExtractor.java diff --git a/scijava-types/src/main/java/org/scijava/types/extract/SubTypeExtractor.java b/types/src/main/java/org/scijava/types/extract/SubTypeExtractor.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/extract/SubTypeExtractor.java rename to types/src/main/java/org/scijava/types/extract/SubTypeExtractor.java diff --git a/scijava-types/src/main/java/org/scijava/types/extract/TypeExtractor.java b/types/src/main/java/org/scijava/types/extract/TypeExtractor.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/extract/TypeExtractor.java rename to types/src/main/java/org/scijava/types/extract/TypeExtractor.java diff --git a/scijava-types/src/main/java/org/scijava/types/extract/TypeReifier.java b/types/src/main/java/org/scijava/types/extract/TypeReifier.java similarity index 99% rename from scijava-types/src/main/java/org/scijava/types/extract/TypeReifier.java rename to types/src/main/java/org/scijava/types/extract/TypeReifier.java index 7fe79105d..ed703f12d 100644 --- a/scijava-types/src/main/java/org/scijava/types/extract/TypeReifier.java +++ b/types/src/main/java/org/scijava/types/extract/TypeReifier.java @@ -38,7 +38,7 @@ import java.util.Optional; import org.scijava.common3.Any; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; import org.scijava.common3.Types; import com.google.common.reflect.TypeToken; diff --git a/scijava-types/src/main/java/org/scijava/types/infer/AnyTypeMapping.java b/types/src/main/java/org/scijava/types/infer/AnyTypeMapping.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/AnyTypeMapping.java rename to types/src/main/java/org/scijava/types/infer/AnyTypeMapping.java diff --git a/scijava-types/src/main/java/org/scijava/types/infer/FunctionalInterfaces.java b/types/src/main/java/org/scijava/types/infer/FunctionalInterfaces.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/FunctionalInterfaces.java rename to types/src/main/java/org/scijava/types/infer/FunctionalInterfaces.java diff --git a/scijava-types/src/main/java/org/scijava/types/infer/GenericAssignability.java b/types/src/main/java/org/scijava/types/infer/GenericAssignability.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/GenericAssignability.java rename to types/src/main/java/org/scijava/types/infer/GenericAssignability.java diff --git a/scijava-types/src/main/java/org/scijava/types/infer/TypeInferenceException.java b/types/src/main/java/org/scijava/types/infer/TypeInferenceException.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/TypeInferenceException.java rename to types/src/main/java/org/scijava/types/infer/TypeInferenceException.java diff --git a/scijava-types/src/main/java/org/scijava/types/infer/TypeMapping.java b/types/src/main/java/org/scijava/types/infer/TypeMapping.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/TypeMapping.java rename to types/src/main/java/org/scijava/types/infer/TypeMapping.java diff --git a/scijava-types/src/main/java/org/scijava/types/infer/TypeVarAssigns.java b/types/src/main/java/org/scijava/types/infer/TypeVarAssigns.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/TypeVarAssigns.java rename to types/src/main/java/org/scijava/types/infer/TypeVarAssigns.java diff --git a/scijava-types/src/main/java/org/scijava/types/infer/WildcardTypeMapping.java b/types/src/main/java/org/scijava/types/infer/WildcardTypeMapping.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/infer/WildcardTypeMapping.java rename to types/src/main/java/org/scijava/types/infer/WildcardTypeMapping.java diff --git a/scijava-types/src/main/java/org/scijava/types/package-info.java b/types/src/main/java/org/scijava/types/package-info.java similarity index 100% rename from scijava-types/src/main/java/org/scijava/types/package-info.java rename to types/src/main/java/org/scijava/types/package-info.java diff --git a/scijava-types/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor b/types/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor similarity index 100% rename from scijava-types/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor rename to types/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor diff --git a/scijava-types/src/test/java/org/scijava/types/ExampleTypes.java b/types/src/test/java/org/scijava/types/ExampleTypes.java similarity index 100% rename from scijava-types/src/test/java/org/scijava/types/ExampleTypes.java rename to types/src/test/java/org/scijava/types/ExampleTypes.java diff --git a/scijava-types/src/test/java/org/scijava/types/NilTest.java b/types/src/test/java/org/scijava/types/NilTest.java similarity index 99% rename from scijava-types/src/test/java/org/scijava/types/NilTest.java rename to types/src/test/java/org/scijava/types/NilTest.java index 5fa510503..ceb74dd2d 100644 --- a/scijava-types/src/test/java/org/scijava/types/NilTest.java +++ b/types/src/test/java/org/scijava/types/NilTest.java @@ -41,7 +41,7 @@ import java.util.List; import org.junit.jupiter.api.Test; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; import org.scijava.types.ExampleTypes.Bag; import org.scijava.types.ExampleTypes.Thing; diff --git a/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java b/types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java similarity index 97% rename from scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java rename to types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java index 3f2e854d4..9ac1226d0 100644 --- a/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java +++ b/types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java @@ -48,7 +48,7 @@ import org.scijava.types.ExampleTypes.BlueThing; import org.scijava.types.ExampleTypes.RedThing; import org.scijava.types.Nil; -import org.scijava.common3.GenericTyped; +import org.scijava.spi.GenericTyped; /** * Tests {@link DefaultTypeReifier}, including core {@link TypeExtractor} @@ -62,7 +62,7 @@ public class DefaultTypeReifierTest { @BeforeEach public void setUp() { - Discoverer serviceLoaderDiscoverer = Discoverer.using(ServiceLoader::load); + Discoverer serviceLoaderDiscoverer = Discoverer.usingProviders(c -> ServiceLoader.load(c).stream()); types = new DefaultTypeReifier(serviceLoaderDiscoverer); } diff --git a/scijava-types/src/test/java/org/scijava/types/infer/InferTypeVariablesTest.java b/types/src/test/java/org/scijava/types/infer/InferTypeVariablesTest.java similarity index 100% rename from scijava-types/src/test/java/org/scijava/types/infer/InferTypeVariablesTest.java rename to types/src/test/java/org/scijava/types/infer/InferTypeVariablesTest.java diff --git a/ui3-test/pom.xml b/ui3-test/pom.xml new file mode 100644 index 000000000..cd9b39bef --- /dev/null +++ b/ui3-test/pom.xml @@ -0,0 +1,206 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-ui3-test + 3.0.0-SNAPSHOT + + SciJava UI: Test Kit + Sample commands and conformance tests, for every toolkit binding. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.ui3.test + + bsd_2 + SciJava developers. + + + + true + + 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-command3 + ${scijava-command3.version} + + + org.scijava + scijava-context + ${scijava-context.version} + + + org.scijava + scijava-ui3 + ${scijava-ui3.version} + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-harvest + ${scijava-harvest.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.slf4j + slf4j-api + + + + + + org.junit.jupiter + junit-jupiter-api + compile + + + + + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/ui3-test/src/main/java/module-info.java b/ui3-test/src/main/java/module-info.java new file mode 100644 index 000000000..ac8868bce --- /dev/null +++ b/ui3-test/src/main/java/module-info.java @@ -0,0 +1,39 @@ +/* + * #%L + * Sample commands and conformance tests, for every toolkit binding. + * %% + * Copyright (C) 2026 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% + */ + +open module org.scijava.ui3.test { + + exports org.scijava.ui3.test; + + requires transitive org.scijava.command3; + requires transitive org.scijava.context; + requires transitive org.scijava.ui3; + requires transitive org.junit.jupiter.api; + +} diff --git a/ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java b/ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java new file mode 100644 index 000000000..939e216bb --- /dev/null +++ b/ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java @@ -0,0 +1,109 @@ +/* + * #%L + * Sample commands and conformance tests, for every toolkit binding. + * %% + * Copyright (C) 2026 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.ui3.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.command3.ClassCommandInfo; +import org.scijava.command3.Commands; +import org.scijava.command3.MenuTree; +import org.scijava.context.Context; + +/** + * What every toolkit's menus must do, asserted once. + * + * @param the toolkit's menu bar + * @author Curtis Rueden + */ +public abstract class MenuConformance { + + /** Builds this toolkit's menu bar from the given tree. */ + protected abstract B menuBar(MenuTree root); + + /** Gets the labels of the bar's top-level menus, in display order. */ + protected abstract List topMenus(B bar); + + /** + * Gets the labels inside the menu at the given path, in display order. + * + * @param bar the menu bar to look in + * @param path the labels to descend through, e.g. {@code "Image", "Adjust"} + */ + protected abstract List items(B bar, String... path); + + /** Menus come out in the declared order, not alphabetically. */ + @Test + public void testMenuOrder() { + assertEquals(List.of("File", "Image", "Process", "Analyze", "Help"), + topMenus(menuBar())); + } + + /** A menu path becomes menus, however deep, and a command an item. */ + @Test + public void testNesting() { + final B bar = menuBar(); + assertEquals(List.of("Adjust"), items(bar, "Image")); + assertEquals(List.of("Brightness/Contrast..."), items(bar, "Image", + "Adjust")); + assertEquals(List.of("Open...", "Quit"), items(bar, "File")); + } + + /** A command with no menu path is absent from the menus, not lost. */ + @Test + public void testCommandWithNoMenuPath() { + assertTrue(commands().stream().anyMatch(c -> c.className().endsWith( + "SampleCommands$Hidden")), "the hidden command should be discovered"); + assertEquals(List.of("About..."), items(menuBar(), "Help")); + } + + // -- Internal methods -- + + /** Builds a bar holding the sample commands, and nothing else. */ + protected B menuBar() { + // NB: only the sample commands, so that another component contributing + // one of its own does not make these assertions brittle. + final List mine = commands().stream() // + .filter(c -> c.className().contains("SampleCommands")) // + .collect(Collectors.toList()); + return menuBar(MenuTree.of(mine)); + } + + /** Discovers the commands, as an application would. */ + protected static List commands() { + try (Context context = Context.create()) { + return Commands.discover(context); + } + } +} diff --git a/ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java b/ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java new file mode 100644 index 000000000..6f5105e5d --- /dev/null +++ b/ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java @@ -0,0 +1,492 @@ +/* + * #%L + * Sample commands and conformance tests, for every toolkit binding. + * %% + * Copyright (C) 2026 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.ui3.test; + +import java.io.File; +import java.util.List; +import java.util.Map; + +import org.scijava.command3.Command; +import org.scijava.command3.Menu; +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; +import org.scijava.execute.Parameters; +import org.scijava.harvest.Group; +import org.scijava.struct.ItemIO; +import org.scijava.struct.StructInstance; + +/** + * The parameters every toolkit binding is asked to render. + *

+ * They live here, rather than beside one toolkit, so that Swing and JavaFX + * demonstrate and are tested against exactly the same declarations - which is + * the only way to notice that a binding has quietly diverged. + *

+ * + * @author Curtis Rueden + */ +public final class SampleCommands { + + private SampleCommands() { + // prevent instantiation of utility class + } + + // -- 1. One of everything -- + + /** Every widget at once, to see how a full dialog reads. */ + public static class KitchenSink implements Runnable { + + @Parameter(style = "message", required = false) + private String message = "One of everything the widgets can do."; + + @Parameter(description = "Shown as the tooltip of this parameter's label") + private String name = "ada"; + + @Parameter(min = "0", max = "100", style = "slider,ticks:4") + private int percent = 50; + + /** Permitted up to a million; the slider spans only the useful part. */ + @Parameter(min = "0", max = "1000000", softMax = "100", + style = "scroll bar") + private long iterations = 10; + + @Parameter(min = "0", max = "1", stepSize = "0.01") + private double weight = 0.25; + + @Parameter(label = "Interpolation") + private Method method = Method.LINEAR; + + @Parameter(label = "Boxed value, not a primitive", required = false) + private Integer boxed = 7; + + @Parameter(label = "Exactly, however many digits") + private java.math.BigDecimal exact = new java.math.BigDecimal("1.25"); + + @Parameter + private char initial = 'a'; + + @Parameter + private boolean enabled = true; + + @Parameter(choices = { "Mean", "Median", "Max" }) + private String statistic = "Mean"; + + @Parameter(required = false) + private File input; + + @Parameter(style = "directory", required = false) + private File outputDir; + + @Parameter(style = "password", required = false) + private String secret = ""; + + @Parameter(style = "text area", required = false) + private String notes = ""; + + @Parameter(io = ItemIO.OUTPUT) + private String summary; + + @Override + public void run() { + summary = name + " " + percent + "% n=" + iterations + " w=" + weight + + " " + method + " boxed=" + boxed + " exact=" + exact + " '" + + initial + "' " + statistic + + (enabled ? " [enabled]" : "") + " in=" + input + " out=" + outputDir + + " notes=" + notes.length() + " chars"; + } + } + + /** Values with names of their own, which need declaring nowhere else. */ + public enum Method { + NEAREST, LINEAR, CUBIC + } + + // -- 2. A group that appears when a box is ticked -- + + @Group(name = "Advanced", collapsible = true, visibleWhen = "showAdvanced") + public static class AdvancedToggle implements Runnable { + + @Parameter + private String file = "data.csv"; + + @Parameter(label = "Show advanced options") + private boolean advanced = false; + + @Parameter(group = "Advanced", min = "1", max = "64") + private int threads = 4; + + @Parameter(group = "Advanced") + private boolean verbose = false; + + @Parameter(io = ItemIO.OUTPUT) + private String summary; + + @SuppressWarnings("unused") + private boolean showAdvanced() { + return advanced; + } + + @Override + public void run() { + summary = file + " threads=" + threads + " verbose=" + verbose; + } + } + + // -- 3. A group whose size a value decides -- + + @Group(name = "Dimensions", membersFrom = "dimensionMembers", + after = "numDims") + public static class GeneratedGroup implements Runnable { + + @Parameter + private String dataset = "stack.tif"; + + @Parameter(callback = "dimsChanged", min = "1", max = "5") + private int numDims = 2; + + private StructInstance> dims = buildDims(2); + + @Parameter(io = ItemIO.OUTPUT) + private String summary; + + @SuppressWarnings("unused") + private void dimsChanged() { + dims = buildDims(numDims); + } + + @SuppressWarnings("unused") + private Object dimensionMembers() { + return dims; + } + + private static StructInstance> buildDims( + final int count) + { + final Parameters.Builder builder = Parameters.builder(); + for (int i = 0; i < count; i++) + builder.add("dim" + i, String.class, "d" + i); + return builder.build(); + } + + @Override + public void run() { + summary = dataset + " " + dims.object(); + } + } + + // -- 4. Choices one value computes for another -- + + public static class ComputedChoices implements Runnable { + + @Parameter(choices = { "stack.tif", "table.csv" }, callback = "fileChanged") + private String file = "stack.tif"; + + @Parameter(choicesFrom = "columnChoices") + private String column = "X"; + + @Parameter(io = ItemIO.OUTPUT) + private String summary; + + @SuppressWarnings("unused") + private void fileChanged() { + // NB: a different file has different columns, and the widget beside + // this one follows without the dialog being told. + if (!columnChoices().contains(column)) column = columnChoices().get(0); + } + + private List columnChoices() { + return file.endsWith(".tif") ? List.of("X", "Y", "Z", "Channel") + : List.of("id", "label", "area", "mean"); + } + + @Override + public void run() { + summary = file + " -> " + column; + } + } + + // -- 5. Values checked as they are typed -- + + public static class LiveValidation implements Runnable { + + @Parameter(validator = "checkCount") + private int count = 5; + + @Parameter(validator = "checkName") + private String name = "ada"; + + @Parameter(io = ItemIO.OUTPUT) + private String summary; + + @SuppressWarnings("unused") + private String checkCount(final Object value) { + final int n = (Integer) value; + return n > 0 && n <= 10 ? null : "Count must be between 1 and 10"; + } + + @SuppressWarnings("unused") + private String checkName(final Object value) { + final String s = String.valueOf(value); + return s.isEmpty() ? "A name is required" : null; + } + + @Override + public void run() { + summary = name + " x" + count; + } + } + + // -- 6. Two values that keep each other in step -- + + public static class LinkedValues implements Runnable { + + @Parameter(callback = "celsiusChanged", stepSize = "0.5") + private double celsius = 0; + + @Parameter(callback = "fahrenheitChanged", stepSize = "0.5") + private double fahrenheit = 32; + + @SuppressWarnings("unused") + private void celsiusChanged() { + fahrenheit = celsius * 9 / 5 + 32; + } + + @SuppressWarnings("unused") + private void fahrenheitChanged() { + celsius = (fahrenheit - 32) * 5 / 9; + } + + @Override + public void run() {} + } + + // -- 7. Two values linked only while a third says so -- + + /** + * The harder version: whether the callbacks do anything is itself a value. + *

+ * NB: a callback runs at most once per {@code set}, so width setting height + * setting width settles rather than spinning. + *

+ */ + public static class LockAspectRatio implements Runnable { + + @Parameter(callback = "widthChanged", min = "1", max = "4096") + private int width = 800; + + @Parameter(callback = "heightChanged", min = "1", max = "4096") + private int height = 600; + + @Parameter(label = "Lock aspect ratio", callback = "lockChanged") + private boolean lock = true; + + @Parameter(style = "message", required = false) + private String ratio = "800 x 600 (1.333:1), locked"; + + private double aspect = 800.0 / 600.0; + + @Parameter(io = ItemIO.OUTPUT) + private String summary; + + @SuppressWarnings("unused") + private void widthChanged() { + if (lock) height = (int) Math.max(1, Math.round(width / aspect)); + else aspect = (double) width / height; + describe(); + } + + @SuppressWarnings("unused") + private void heightChanged() { + if (lock) width = (int) Math.max(1, Math.round(height * aspect)); + else aspect = (double) width / height; + describe(); + } + + @SuppressWarnings("unused") + private void lockChanged() { + // NB: locking adopts the ratio on screen, rather than restoring the + // one in force when it was last unlocked. + if (lock) aspect = (double) width / height; + describe(); + } + + private void describe() { + ratio = String.format("%d x %d (%.3f:1)%s", width, height, (double) width / + height, lock ? ", locked" : ""); + } + + @Override + public void run() { + summary = width + "x" + height; + } + } + + + // -- Commands with menu paths, for an application shell -- + + @Plugin(type = Command.class) + @Menu(path = "File>Open...", weight = 1, accelerator = "^O") + public static class Open implements Command { + + @Parameter + private File file; + + @Parameter(io = ItemIO.OUTPUT) + private String opened; + + @Override + public void run() { + opened = "Opened " + file; + } + } + + @Plugin(type = Command.class) + @Menu(path = "File>Quit", weight = 90, accelerator = "^Q") + public static class Quit implements Command { + + @Override + public void run() { + // NB: a command reaching the application it runs in is what an + // application layer is for; until there is one, the demo exits. + System.exit(0); + } + } + + @Plugin(type = Command.class) + @Menu(path = "Image>Adjust>Brightness/Contrast...", weight = 10, + accelerator = "^+C") + public static class BrightnessContrast implements Command { + + @Parameter(min = "0", max = "255", style = "slider,ticks:4") + private int minimum = 0; + + @Parameter(min = "0", max = "255", style = "slider,ticks:4") + private int maximum = 255; + + @Parameter(io = ItemIO.OUTPUT) + private String range; + + @Override + public void run() { + range = "Display range: " + minimum + "-" + maximum; + } + } + + @Plugin(type = Command.class) + @Menu(path = "Process>Filters>Gaussian Blur...", weight = 20) + @Group(name = "Advanced", collapsible = true, visibleWhen = "showAdvanced") + public static class GaussianBlur implements Command { + + @Parameter(min = "0", softMax = "20", stepSize = "0.1", + validator = "checkSigma") + private double sigma = 2.0; + + @Parameter(label = "Show advanced options") + private boolean advanced = false; + + @Parameter(group = "Advanced", choices = { "Reflect", "Zero", "Wrap" }) + private String edges = "Reflect"; + + @Parameter(group = "Advanced", min = "1", max = "64") + private int threads = 4; + + @Parameter(io = ItemIO.OUTPUT) + private String applied; + + @SuppressWarnings("unused") + private boolean showAdvanced() { + return advanced; + } + + @SuppressWarnings("unused") + private String checkSigma(final Object value) { + return (Double) value > 0 ? null : "Sigma must be greater than zero"; + } + + @Override + public void run() { + applied = "Blurred with sigma " + sigma + ", " + edges + " edges, " + + threads + " threads"; + } + } + + @Plugin(type = Command.class) + @Menu(path = "Analyze>Measure...", weight = 30, accelerator = "^M") + public static class Measure implements Command { + + @Parameter(callback = "sourceChanged", choices = { "stack.tif", + "table.csv" }) + private String source = "stack.tif"; + + @Parameter(choicesFrom = "columnChoices") + private String column = "X"; + + @Parameter(io = ItemIO.OUTPUT) + private String measured; + + @SuppressWarnings("unused") + private void sourceChanged() { + if (!columnChoices().contains(column)) column = columnChoices().get(0); + } + + private List columnChoices() { + return source.endsWith(".tif") ? List.of("X", "Y", "Z", "Channel") + : List.of("id", "label", "area", "mean"); + } + + @Override + public void run() { + measured = "Measured " + column + " of " + source; + } + } + + @Plugin(type = Command.class) + @Menu(path = "Help>About...", weight = 100) + public static class About implements Command { + + @Parameter(style = "message", required = false) + private String about = "A SciJava3 application: menus from the " + + "annotation index, dialogs from whichever toolkit binding is running."; + + @Override + public void run() {} + } + + /** Not in any menu, but perfectly runnable - and findable by searching. */ + @Plugin(type = Command.class) + public static class Hidden implements Command { + + @Parameter(io = ItemIO.OUTPUT) + private String ran = "The hidden command ran"; + + @Override + public void run() {} + } +} diff --git a/ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java b/ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java new file mode 100644 index 000000000..91802acc6 --- /dev/null +++ b/ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java @@ -0,0 +1,227 @@ +/* + * #%L + * Sample commands and conformance tests, for every toolkit binding. + * %% + * Copyright (C) 2026 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.ui3.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.scijava.context.Access; +import org.scijava.execute.Executables; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.Widget; +import org.scijava.ui3.WidgetPanel; +import org.scijava.ui3.WidgetPanels; + +/** + * What every toolkit binding must do, asserted once. + *

+ * A binding extends this and says how to make its widgets; the assertions are + * about the contracts, so a binding that passes them behaves like the others + * wherever it matters. Anything a binding does beyond this - which + * control it chooses, how it lays a panel out - belongs in its own tests. + *

+ * + * @param the binding's widget type + * @author Curtis Rueden + */ +public abstract class WidgetConformance { + + /** Builds the widgets for the given model, as this toolkit makes them. */ + protected abstract WidgetPanel panel(ParameterModel model); + + /** + * Gets the kind of widget this binding uses for the given parameter kind, + * so that the shared tests can name types without knowing the toolkit's. + */ + protected abstract Class widgetType(Kind kind); + + /** The kinds of widget every binding provides. */ + public enum Kind { + MESSAGE, TEXT, NUMBER, TOGGLE, CHOICE, FILE, PANEL + } + + /** Every parameter type gets the kind of widget it should. */ + @Test + public void testWidgetPerParameterType() { + final WidgetPanel panel = panel(model(new SampleCommands.KitchenSink())); + assertEquals(List.of( // + widgetType(Kind.MESSAGE), // message + widgetType(Kind.TEXT), // name + widgetType(Kind.NUMBER), // percent + widgetType(Kind.NUMBER), // iterations + widgetType(Kind.NUMBER), // weight + widgetType(Kind.CHOICE), // method, an enum + widgetType(Kind.NUMBER), // boxed, an Integer rather than an int + widgetType(Kind.TEXT), // exact, a BigDecimal no spinner can step + widgetType(Kind.TEXT), // initial, a char + widgetType(Kind.TOGGLE), // enabled + widgetType(Kind.CHOICE), // statistic + widgetType(Kind.FILE), // input + widgetType(Kind.FILE), // outputDir + widgetType(Kind.TEXT), // secret + widgetType(Kind.TEXT)), // notes + types(panel)); + } + + /** An enum offers its own constants, with no annotation. */ + @Test + public void testEnumChoices() { + final ParameterModel model = model(new SampleCommands.KitchenSink()); + assertEquals(List.of(SampleCommands.Method.values()), panel(model) + .widgets().get(5).node().choices()); + } + + /** A group appears, with its own widgets, when a value asks for it. */ + @Test + public void testGroupAppearsWhenToggled() { + final ParameterModel model = model(new SampleCommands.AdvancedToggle()); + assertEquals(2, panel(model).widgets().size()); + + model.set("advanced", true); + + final WidgetPanel panel = panel(model); + assertEquals(3, panel.widgets().size()); + final W group = panel.widgets().get(2); + assertFalse(group.isLabeled(), "a group carries its own title"); + final WidgetPanel nested = assertInstanceOf(WidgetPanel.class, group); + assertEquals(List.of(widgetType(Kind.NUMBER), widgetType(Kind.TOGGLE)), + types(nested)); + } + + /** The number of widgets follows a value, not the class's fields. */ + @Test + public void testGeneratedGroupGrows() { + final ParameterModel model = model(new SampleCommands.GeneratedGroup()); + assertEquals(2, nested(panel(model)).widgets().size()); + + model.set("numDims", 4); + + assertEquals(4, nested(panel(model)).widgets().size()); + } + + /** + * A widget reads its node out of the current tree, not the one it was built + * from, so computed choices stay in step even when nothing was rebuilt. + */ + @Test + public void testWidgetSeesRecomputedChoices() { + final ParameterModel model = model(new SampleCommands.ComputedChoices()); + final WidgetPanel panel = panel(model); + final W column = panel.widgets().get(1); + assertEquals(List.of("X", "Y", "Z", "Channel"), column.node().choices()); + + model.set("file", "table.csv"); + + // NB: the same widget object, never rebuilt. + assertEquals(List.of("id", "label", "area", "mean"), column.node() + .choices()); + panel.refresh(); + assertEquals("id", model.get("column")); + } + + /** A widget writes through the model, so callbacks run. */ + @Test + public void testEditRunsCallback() { + final ParameterModel model = model(new SampleCommands.LockAspectRatio()); + panel(model); // NB: the widgets exist and are listening + + model.set("width", 400); + assertEquals(300, model.get("height")); + + model.set("lock", false); + model.set("width", 800); + assertEquals(300, model.get("height")); + } + + /** Problems are reported per parameter, and clear when fixed. */ + @Test + public void testValidation() { + final ParameterModel model = model(new SampleCommands.LiveValidation()); + final WidgetPanel panel = panel(model); + assertTrue(model.problems().isEmpty()); + + model.set("count", 42); + // NB: showing them must not throw, whatever a binding does to show them. + panel.showProblems(model.problems()); + assertEquals("Count must be between 1 and 10", model.problems().get( + "count")); + + model.set("count", 3); + panel.showProblems(model.problems()); + assertTrue(model.problems().isEmpty()); + } + + /** A file parameter is rendered, rather than falling through to text. */ + @Test + public void testFileParameter() { + final ParameterModel model = model(new SampleCommands.KitchenSink()); + final W input = panel(model).widgets().get(11); + assertEquals(File.class, org.scijava.ui3.Widgets.type(input.node())); + assertEquals(widgetType(Kind.FILE), input.getClass()); + } + + // -- Internal methods -- + + /** Builds the model for a sample command, as a container would. */ + protected static ParameterModel model(final Runnable object) { + return new ParameterModel(Executables.executableOf(object, Access.lookupIn( + object.getClass())).create()); + } + + /** Builds the widgets, using the toolkit's own panel factory. */ + protected static WidgetPanel build( + final ParameterModel model, final List> factories, + final org.scijava.ui3.WidgetPanelFactory panels) + { + @SuppressWarnings("unchecked") + final WidgetPanel panel = (WidgetPanel) new WidgetPanels<>(List.copyOf( + factories), panels).build(model); + return panel; + } + + private WidgetPanel nested(final WidgetPanel panel) { + return panel.widgets().stream() // + .filter(WidgetPanel.class::isInstance) // + .map(w -> (WidgetPanel) w) // + .findFirst().orElseThrow(); + } + + private static List> types(final WidgetPanel panel) { + return panel.widgets().stream().map(Object::getClass) // + .collect(Collectors.toList()); + } +} diff --git a/ui3/pom.xml b/ui3/pom.xml new file mode 100644 index 000000000..85e2be36c --- /dev/null +++ b/ui3/pom.xml @@ -0,0 +1,167 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-ui3 + 3.0.0-SNAPSHOT + + SciJava UI + Toolkit-agnostic contracts for widgets and input harvesting. + https://github.com/scijava/scijava + 2026 + + SciJava + https://scijava.org/ + + + + Simplified BSD License + repo + + + + + + ctrueden + Curtis Rueden + + founder + lead + reviewer + support + maintainer + + + + gselzer + Gabriel Selzer + + founder + debugger + reviewer + support + + + + + + Johannes Schindelin + dscho + + + + + + Image.sc Forum + https://forum.image.sc/tag/scijava + + + + + scm:git:https://github.com/scijava/scijava + scm:git:git@github.com:scijava/scijava + HEAD + https://github.com/scijava/scijava + + + GitHub Issues + https://github.com/scijava/scijava/issues + + + GitHub Actions + https://github.com/scijava/scijava/actions + + + + org.scijava.ui3 + + bsd_2 + SciJava developers. + + + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.version} + + + org.scijava + scijava-execute + ${scijava-execute.version} + + + org.scijava + scijava-harvest + ${scijava-harvest.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + org.scijava + scijava-struct + ${scijava-struct.version} + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + + + + maven-enforcer-plugin + + + enforce-package-rules + + enforce + + test + + + + + + + + + + + maven-javadoc-plugin + + + https://javadoc.scijava.org/Java11/ + + + + + + diff --git a/ui3/src/main/java/module-info.java b/ui3/src/main/java/module-info.java new file mode 100644 index 000000000..fdd9e691b --- /dev/null +++ b/ui3/src/main/java/module-info.java @@ -0,0 +1,40 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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% + */ + +module org.scijava.ui3 { + + exports org.scijava.ui3; + + requires transitive org.scijava.execute; + requires transitive org.scijava.harvest; + requires transitive org.scijava.common3; + requires org.scijava.priority; + requires org.slf4j; + +} diff --git a/ui3/src/main/java/org/scijava/ui3/AbstractWidget.java b/ui3/src/main/java/org/scijava/ui3/AbstractWidget.java new file mode 100644 index 000000000..22de0cfe3 --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/AbstractWidget.java @@ -0,0 +1,128 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; + +/** + * The bookkeeping every widget does, whatever toolkit it is made of. + *

+ * NB: this is here because three bindings wrote it identically. Two would have + * been a coincidence; three is the shape of the problem - a widget must find + * its node in the current tree, must write through the model rather + * than to the parameter, and must not mistake its own refresh for the user + * typing. None of that is a statement about controls. + *

+ * + * @author Curtis Rueden + */ +public abstract class AbstractWidget implements Widget { + + private final ParameterNode node; + private final ParameterModel model; + private final String key; + + private boolean refreshing; + + /** + * @param node the parameter to edit, or null for a panel standing for no + * parameter + * @param model the values to read and write + */ + protected AbstractWidget(final ParameterNode node, + final ParameterModel model) + { + this.node = node; + this.model = model; + this.key = node == null ? null : node.member() // + .map(m -> m.member().key()).orElse(null); + } + + @Override + public ParameterNode node() { + if (key == null) return node; // NB: a group, or the whole dialog + // NB: the tree is rebuilt whenever a value changes, so the node handed to + // this widget at construction goes stale - its choices in particular, + // which a computed `choicesFrom` resolves afresh each time. Reading the + // node back out of the current tree is what keeps a chooser showing the + // values the other widgets now imply. + return model.tree().find(key).orElse(node); + } + + /** Gets the values this widget reads and writes. */ + public ParameterModel model() { + return model; + } + + @Override + public final void refresh() { + // NB: writing a value into a control fires its listeners, which would + // write it straight back into the model and, where a callback is + // involved, keep going. The flag is what stops a refresh from looking + // like the user typing. + refreshing = true; + try { + doRefresh(); + } + finally { + refreshing = false; + } + } + + // -- Internal methods -- + + /** Re-reads the parameter's value into the controls. */ + protected abstract void doRefresh(); + + /** Gets the parameter's name. */ + protected String key() { + if (key == null) throw new IllegalStateException("Not a parameter widget"); + return key; + } + + /** Gets the parameter's current value. */ + protected Object value() { + return model.get(key()); + } + + /** + * Reports that the user changed this widget's value. + *

+ * It goes through the model rather than to the parameter directly, which is + * what runs the callback and, if the shape of the dialog depends on this + * value, rebuilds it. + *

+ */ + protected void update(final Object value) { + if (refreshing) return; + model.set(key(), value); + } +} diff --git a/ui3/src/main/java/org/scijava/ui3/InputHarvester.java b/ui3/src/main/java/org/scijava/ui3/InputHarvester.java new file mode 100644 index 000000000..418596b8a --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/InputHarvester.java @@ -0,0 +1,79 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import org.scijava.execute.Execution; +import org.scijava.execute.Preprocessor; +import org.scijava.harvest.ParameterModel; +import org.scijava.priority.Priority; + +/** + * Asks the user for the inputs a run still needs. + *

+ * It is a {@link Preprocessor}, so a run harvests its inputs simply by going + * through a {@link org.scijava.execute.Runner} whose chain contains one. A + * user who dismisses the dialog {@linkplain Execution#decline declines} the + * run, which is an ordinary outcome and not an error. + *

+ *

+ * It runs last among preprocessors, so that everything else - filled-in + * services, values supplied by the caller, preconditions - has already had its + * say and the user is asked only for what remains. + *

+ * + * @author Curtis Rueden + */ +public abstract class InputHarvester implements Preprocessor { + + @Override + public void process(final Execution execution) { + final ParameterModel model = new ParameterModel(execution.executable()); + if (model.tree().nodes().isEmpty()) return; // nothing left to ask + if (!harvest(model)) execution.decline("Canceled by user"); + } + + /** + * Puts the given parameters in front of the user and waits for an answer. + *

+ * An implementation sets values through the model, which is what + * runs callbacks and reshapes the dialog; it should not write to the + * parameters directly. + *

+ * + * @param model the values to gather, and the shape to render + * @return true if the user accepted, false if they dismissed the dialog + */ + public abstract boolean harvest(ParameterModel model); + + @Override + public double priority() { + return Priority.VERY_LOW; + } +} diff --git a/ui3/src/main/java/org/scijava/ui3/Widget.java b/ui3/src/main/java/org/scijava/ui3/Widget.java new file mode 100644 index 000000000..029753720 --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/Widget.java @@ -0,0 +1,70 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import org.scijava.harvest.ParameterNode; + +/** + * A view of one parameter. + *

+ * A widget knows the parameter it edits and nothing about the dialog it sits + * in. Toolkit bindings extend this with whatever component type they deal in; + * this interface stays free of any toolkit, so that the model, the builder and + * the tests can speak of widgets without importing Swing. + *

+ * + * @author Curtis Rueden + */ +public interface Widget { + + /** + * Gets what this widget edits: the parameter, its label, its choices. + * + * @return the node, or null for a panel that stands for no parameter + */ + ParameterNode node(); + + /** Re-reads the parameter, in case something else changed its value. */ + void refresh(); + + /** + * Gets whether the dialog should put a label beside this widget. A widget + * that says no gets the whole row. + *

+ * NB: this lives here rather than in a toolkit because it is a statement + * about the parameter's presentation - a group carries its own + * title, a message is its own text - and every toolkit would otherwise + * answer it identically. + *

+ */ + default boolean isLabeled() { + return true; + } +} diff --git a/ui3/src/main/java/org/scijava/ui3/WidgetFactory.java b/ui3/src/main/java/org/scijava/ui3/WidgetFactory.java new file mode 100644 index 000000000..ea8f3fa4d --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/WidgetFactory.java @@ -0,0 +1,79 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.priority.Priority; + +/** + * Creates widgets of one kind, for the parameters it recognizes. + *

+ * Contribute one as a plugin: + *

+ * + *
+ * @Plugin(type = WidgetFactory.class)
+ * public class SwingNumberWidgetFactory implements WidgetFactory<SwingWidget> { ... }
+ * 
+ *

+ * Where several factories accept a parameter, the highest priority wins - so a + * specific widget for a particular type can outrank a general one. + *

+ * + * @param the kind of widget this factory makes, which is how a toolkit + * keeps its own widgets together + * @author Curtis Rueden + */ +public interface WidgetFactory { + + /** Gets whether this factory can make a widget for the given parameter. */ + boolean supports(ParameterNode node); + + /** + * Makes a widget for the given parameter. + * + * @param node the parameter to edit + * @param model the values to read and write; setting through it is what + * runs callbacks and reshapes the dialog + * @param panels makes panels, for a widget whose parameter contains others + * @return the new widget + */ + W create(ParameterNode node, ParameterModel model, + WidgetPanelFactory panels); + + /** Gets the kind of widget this factory makes. */ + Class widgetType(); + + /** Sorts factories that accept the same parameter. See {@link Priority}. */ + default double priority() { + return Priority.NORMAL; + } +} diff --git a/ui3/src/main/java/org/scijava/ui3/WidgetPanel.java b/ui3/src/main/java/org/scijava/ui3/WidgetPanel.java new file mode 100644 index 000000000..061a80e8c --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/WidgetPanel.java @@ -0,0 +1,75 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import java.util.List; +import java.util.Map; + +/** + * A widget that holds other widgets: a whole dialog, or one box within it. + *

+ * NB: a panel is itself a {@link Widget}, which is what lets a group nest + * inside another panel with no special case - a dialog is a list of widgets, + * some of which happen to contain more. + *

+ * + * @param the kind of widget this panel holds + * @author Curtis Rueden + */ +public interface WidgetPanel extends Widget { + + /** Gets the widgets in this panel, in display order. */ + List widgets(); + + @Override + default void refresh() { + widgets().forEach(Widget::refresh); + } + + /** + * Marks the parameters the model reports problems with, and clears the + * marks on the rest. + *

+ * NB: a panel decides how a problem looks - a red label, an icon, a tooltip + * - but that there is something to show, and that it is shown per + * parameter and cleared when fixed, is the same everywhere. + *

+ * + * @param problems what is wrong, by parameter name, as + * {@link org.scijava.harvest.ParameterModel#problems()} reports + */ + default void showProblems(final Map problems) { + for (final Widget widget : widgets()) { + if (widget instanceof WidgetPanel) { + ((WidgetPanel) widget).showProblems(problems); + } + } + } +} diff --git a/ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java b/ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java new file mode 100644 index 000000000..039ce84bb --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java @@ -0,0 +1,59 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import java.util.List; + +import org.scijava.harvest.ParameterNode; + +/** + * Makes panels to hold widgets. + *

+ * NB: a factory is handed to {@link WidgetFactory#create}, which is what lets + * a widget build a panel inside itself. That is how a parameter + * containing other parameters - a chosen implementation, a settings object, a + * generated group - is rendered without the dialog knowing the shape in + * advance. + *

+ * + * @param the kind of widget these panels hold + * @author Curtis Rueden + */ +public interface WidgetPanelFactory { + + /** + * Makes a panel holding the given widgets. + * + * @param group the node these widgets belong to, or null at the top level + * @param widgets what the panel holds + * @return the new panel, which is itself a widget + */ + W create(ParameterNode group, List widgets); +} diff --git a/ui3/src/main/java/org/scijava/ui3/WidgetPanels.java b/ui3/src/main/java/org/scijava/ui3/WidgetPanels.java new file mode 100644 index 000000000..a493b11dd --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/WidgetPanels.java @@ -0,0 +1,112 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Turns the shape a dialog should have into the widgets that show it. + *

+ * The walk is the same for every toolkit - descend the tree, ask the + * highest-priority factory that accepts each parameter, wrap each group in a + * panel - so it lives here, and a toolkit supplies only the factories. + *

+ * + * @param the kind of widget being built + * @author Curtis Rueden + */ +public class WidgetPanels { + + private static final Logger log = LoggerFactory.getLogger( + WidgetPanels.class); + + private final List> factories; + private final WidgetPanelFactory panels; + + /** + * @param factories the widgets available, of which the highest-priority one + * accepting a parameter wins + * @param panels makes the panel for the dialog and for each group + */ + public WidgetPanels(final List> factories, + final WidgetPanelFactory panels) + { + this.factories = new ArrayList<>(factories); + this.factories.sort(Comparator.comparingDouble(WidgetFactory::priority) + .reversed()); + this.panels = panels; + } + + /** Builds the widgets for the model's current shape. */ + public W build(final ParameterModel model) { + return panels.create(null, widgets(model.tree().nodes(), model)); + } + + /** + * Builds the widget for one node: a panel if it is a group, otherwise + * whichever widget accepts the parameter. + * + * @return the widget, or null if no factory accepts the parameter + */ + public W build(final ParameterNode node, final ParameterModel model) { + if (node.isGroup()) { + return panels.create(node, widgets(node.children(), model)); + } + for (final WidgetFactory factory : factories) { + if (factory.supports(node)) return factory.create(node, model, panels); + } + // NB: a parameter no widget accepts is left out rather than fatal, so + // that an unusual type does not make the whole dialog unavailable. It is + // worth saying, though: a dialog silently missing one row is a puzzle. + log.warn("No widget for parameter '{}'; it will not be shown", node + .label()); + return null; + } + + // -- Helper methods -- + + private List widgets(final List nodes, + final ParameterModel model) + { + final List widgets = new ArrayList<>(); + for (final ParameterNode node : nodes) { + final W widget = build(node, model); + if (widget != null) widgets.add(widget); + } + return widgets; + } +} diff --git a/ui3/src/main/java/org/scijava/ui3/Widgets.java b/ui3/src/main/java/org/scijava/ui3/Widgets.java new file mode 100644 index 000000000..a9efd55ac --- /dev/null +++ b/ui3/src/main/java/org/scijava/ui3/Widgets.java @@ -0,0 +1,189 @@ +/* + * #%L + * Toolkit-agnostic contracts for widgets and input harvesting. + * %% + * Copyright (C) 2026 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.ui3; + +import java.util.Optional; + +import org.scijava.convert3.Converters; +import org.scijava.execute.ParameterMember; +import org.scijava.harvest.ParameterNode; +import org.scijava.struct.MemberInstance; + +/** + * What every widget needs to read off a parameter: its bounds, its style + * hints, whether it is a number at all. + * + * @author Curtis Rueden + */ +public final class Widgets { + + private Widgets() { + // prevent instantiation of utility class + } + + /** Gets the raw type of the parameter a node edits. */ + public static Class type(final ParameterNode node) { + final Optional> member = node.member(); + if (member.isEmpty()) return null; + return org.scijava.common3.Types.raw(member.get().member().type()); + } + + /** Gets whether the node edits a number. */ + public static boolean isNumber(final ParameterNode node) { + final Class type = type(node); + return type != null && (Number.class.isAssignableFrom(box(type))); + } + + /** Gets whether the node edits a true/false value. */ + public static boolean isBoolean(final ParameterNode node) { + final Class type = type(node); + return type == boolean.class || type == Boolean.class; + } + + /** Gets whether the node edits text. */ + public static boolean isText(final ParameterNode node) { + final Class type = type(node); + return type == String.class || type == char.class || type == + Character.class; + } + + /** Gets one piece of a parameter's metadata. */ + public static Optional attr(final ParameterNode node, + final String key) + { + return node.member().map(MemberInstance::member) // + .filter(ParameterMember.class::isInstance) // + .flatMap(m -> ((ParameterMember) m).attr(key)); + } + + /** + * Gets whether the parameter carries the given style hint. + * + * @see org.scijava.execute.Parameter#style() + */ + public static boolean isStyle(final ParameterNode node, final String hint) { + return attr(node, ParameterMember.STYLE).map(style -> { + for (final String part : style.split(",")) + if (part.trim().equalsIgnoreCase(hint)) return true; + return false; + }).orElse(false); + } + + /** + * Gets the value of a style hint written as {@code name:value}, such as the + * {@code format} of a number widget. + */ + public static Optional styleValue(final ParameterNode node, + final String name) + { + return attr(node, ParameterMember.STYLE).flatMap(style -> { + for (final String part : style.split(",")) { + final int colon = part.indexOf(':'); + if (colon < 0) continue; + if (part.substring(0, colon).trim().equalsIgnoreCase(name)) { + return Optional.of(part.substring(colon + 1).trim()); + } + } + return Optional.empty(); + }); + } + + /** Gets the parameter's declared minimum, in the parameter's own type. */ + public static Number min(final ParameterNode node) { + return number(node, ParameterMember.MIN); + } + + /** Gets the parameter's declared maximum, in the parameter's own type. */ + public static Number max(final ParameterNode node) { + return number(node, ParameterMember.MAX); + } + + /** + * Gets the smallest value a slider should span, falling back to + * {@link #min}. + */ + public static Number softMin(final ParameterNode node) { + final Number soft = number(node, ParameterMember.SOFT_MIN); + return soft == null ? min(node) : soft; + } + + /** + * Gets the largest value a slider should span, falling back to + * {@link #max}. + */ + public static Number softMax(final ParameterNode node) { + final Number soft = number(node, ParameterMember.SOFT_MAX); + return soft == null ? max(node) : soft; + } + + /** Gets the parameter's step size, defaulting to one. */ + public static Number stepSize(final ParameterNode node) { + final Number step = number(node, ParameterMember.STEP_SIZE); + return step == null ? toType("1", type(node)) : step; + } + + /** + * Reads a value in the given type, for a widget that has only text. + *

+ * NB: this is {@code scijava-convert3}, not a parser of its own: a widget + * turning text into a value is doing exactly what a script or a command + * line does, and one of them getting it differently right would be a bug + * nobody could see. Half-typed text is not a value, so it converts to + * nothing rather than to zero. + *

+ * + * @return the value, or null if the text is not one of that type + */ + public static Number toType(final String text, final Class type) { + if (text == null || type == null) return null; + return Converters.get().tryConvert(text, box(type)) // + .filter(Number.class::isInstance).map(Number.class::cast).orElse(null); + } + + /** Gets the wrapper type for a primitive, or the type itself. */ + public static Class box(final Class type) { + if (type == null || !type.isPrimitive()) return type; + if (type == byte.class) return Byte.class; + if (type == short.class) return Short.class; + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == float.class) return Float.class; + if (type == double.class) return Double.class; + if (type == boolean.class) return Boolean.class; + if (type == char.class) return Character.class; + return type; + } + + // -- Helper methods -- + + private static Number number(final ParameterNode node, final String key) { + return attr(node, key).map(text -> toType(text, type(node))).orElse(null); + } +}