From 5e9836aa9289ff8228c9c188b6a587e0f8a6218f Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 11:03:47 -0500 Subject: [PATCH 01/59] docs: add SciJava Common migration plan Adds docs/migration-plan.md (strategy, phases, cross-cutting design decisions) and docs/migration.md (the class-by-class mapping table from SciJava Common to its SciJava3 replacement, standard library equivalent, or "dropped"). Both are excluded from the Sphinx build; they are development docs, not part of the user-facing site. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/conf.py | 2 + docs/migration-plan.md | 377 +++++++++++++++++++++++++++++++++++++++++ docs/migration.md | 115 +++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 docs/migration-plan.md create mode 100644 docs/migration.md diff --git a/docs/conf.py b/docs/conf.py index e6315b705..e3754be1a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -66,6 +66,8 @@ ".DS_Store", "README.md", "examples/README.md", + "migration.md", + "migration-plan.md", ] # -- MyST-Parser/MyST-NB configuration --------------------------------------- diff --git a/docs/migration-plan.md b/docs/migration-plan.md new file mode 100644 index 000000000..08efe260d --- /dev/null +++ b/docs/migration-plan.md @@ -0,0 +1,377 @@ +# 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, + plugin classes public in exported packages, constructor injection rather + than private-field reflection, resources under `META-INF/`. The one thing we + give up is `setAccessible`-based injection into private fields. +- **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. +- **Names are case by case.** Where an SJ3 package would collide with an SJC + one, pick a natural collision-free noun if one exists; otherwise use a `3` + suffix (`common3`, `io3`). 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. **`scijava-legacy` bridges the other way** (SJ3 → SJC), and is the only + place in this repository permitted to depend on `scijava-common`. + +## 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-legacy` — intentional; it is the bridge. +- `scijava-ops-image` / `scijava-ops-flim` tests — `util.MersenneTwisterFast` + (16 usages), `util.LongArray`, `util.ListUtils`, `io.location.FileLocation`, + `Context`. Removable today (Phase 0). +- `scijava-ops-tutorial` — one import in main code. + +## 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. + +**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 + +- Index resources are safe: `META-INF/json/...` is not a valid package name, + so it stays readable via `ClassLoader.getResources` from named modules. +- Instantiating a public class with a public no-arg constructor in an + **exported** package needs no `opens`. +- `setAccessible` on private fields — how SJC injects `@Parameter` + (`Context.java:510`, `ServiceHelper.java:317`) — **does** require `opens`. + This is the one thing forcing every plugin module to open itself. + +Therefore the SJ3 context uses **constructor injection** (or an explicit +`initialize(Context)`), so that `exports` suffices and `@Plugin` coexists with +JPMS. + +### 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`, fix the `ops-tutorial` import. + Afterwards only `scijava-legacy` and `scijava-code-api` depend on SJC. +- 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). + +### Phase 1 — Standalone leaves + +No application context required by anything in this phase. + +- **`scijava-spi`** (new): pure interfaces, no behavior, no dependencies — + `Versioned`, `GenericTyped`, `Named`, `Identifiable`, `Locatable`, `Typed`, + `Disposable`. `Versioned` and `GenericTyped` move here from `common3`, which + retains deprecated sub-interfaces so implementors stay source-compatible. + `common3` does **not** gain a transitive dependency on it. + - The test for membership: *if a class can implement it without knowing + about any SciJava framework, it is SPI.* `Cancelable` fails that test + (cancellation concerns a running thing — it belongs with the execution + layer, near `Progress`/`Task`), 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. +- **`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 a `Discoverer` backed by the + index. This is what supplies plugin metadata without loading classes. +- **`scijava-io3`** (new, `org.scijava.io3.{location, handle, nio}`): ports + `io.location`, `io.handle`, `io.nio` and `ByteBank`. Handles and resolvers + are found via `Discoverer` + `Priority`. The external `scijava-io-http` + folds in later as `org.scijava.io3.http`. +- **`scijava-events`** (new, `org.scijava.events`): a clean-room event bus, + no context, designed for typed topics and weak-reference subscribers. Not a + port of the bushe fork — rewriting removes the attribution obligation. The + external `scijava-listeners` folds in here. +- **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 **constructor-based** dependency injection; plugin metadata + from `scijava-index`; instantiation via `Discoverer`; ordering via + `scijava-priority`. Plus the core services: object registry, prefs, thread, + app/status. + - 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:** a service hosting an SJC `Context` inside the SJ3 + context, plus 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 + +- **Factor the framework-neutral member parsing out of `scijava-ops-engine`** + into `scijava-struct` (or a `scijava-struct-reflect` beside it): + `ClassParameterMemberParser`, `Field`/`MethodParameterMemberParser`, + `FunctionalParameters`, `SynthesizedParameterMember`. The Op-specific pieces + (`OpDependency` members, retyping/resizing) stay in `ops-engine`. The + execution layer must **not** depend on `ops-engine`; if a dependency is + useful it runs the other way, `ops-engine` → execution layer. +- **Execution module** (name TBD): pre/postprocessor chains, input resolution, + a `@Parameter` equivalent, and a runner — built on `scijava-struct`. + `Cancelable`'s replacement lives here. +- **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. + +### Phase 4 — UI and desktop + +- **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. +- **Drop** the opinionated application shell: `ApplicationFrame`, `Desktop`, + `StatusBar`, `ToolBar`. The SJ3 UI API is minimal. +- **`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` | Minimal UI API; app shell dropped (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-legacy` 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. + +## Open decisions + +- **Names:** the execution-layer module, and `scijava-convert3`. +- **Splitting `scijava-legacy`** into an SJC-only bridge and a separate + ImageJ-specific module, so that a bridge needing only SJC does not drag in + `imagej-common`. Agreed in principle; not blocking, so it is deferred until + the bridge content grows. +- **`util.Timing` and `util.TreeNode`:** port or drop, pending evidence of + downstream usage. +- **`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..11fec92b7 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,115 @@ +# 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` | `org.scijava.common3` (Phase 1) | planned | +| `BoolArray`, `ByteArray`, `CharArray`, `DoubleArray`, `FloatArray`, `IntArray`, `LongArray`, `ObjectArray`, `ShortArray`, `PrimitiveArray` | `org.scijava.collections.*` | ported | +| `Bytes` | `org.scijava.common3` (Phase 1) | planned | +| `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` | `scijava-convert3` (Phase 2); already deprecated in SJC | planned | +| `DebugUtils` | `org.scijava.common3.Threads` | ported | +| `DefaultTreeNode`, `TreeNode` | on hold — only `imagej-common` uses it; may move there | open | +| `DigestUtils` | `org.scijava.common3` (Phase 1) | planned | +| `FileUtils` | `org.scijava.common3` (non-URL parts, Phase 1); URL parts in `common3.URLs` | planned | +| `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 | +| `LineOutputStream`, `ReadInto`, `StringMaker`, `Query` | none — inline as needed | dropped | +| `ListUtils` | `org.scijava.common3` (Phase 1) | planned | +| `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` (Phase 1) | planned | +| `PlatformUtils` | `org.scijava.common3.Platforms` | ported | +| `POM` | `org.scijava.meta.POM` | ported | +| `Prefs` | `scijava-context` prefs service (Phase 2); already deprecated in SJC | planned | +| `ProcessUtils` | `org.scijava.common3` (Phase 1) | planned | +| `PropertiesHelper` | none — inline | dropped | +| `Sizable`, `SizableArrayList` | none — the reflective-resizing rationale died with Java 17 | dropped | +| `StringUtils` | `org.scijava.common3` (Phase 1) | planned | +| `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` | `org.scijava.common3` (Phase 1) | planned | +| `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` (Phase 1; currently `common3`) | planned | +| `Named`, `Identifiable`, `Locatable`, `Typed`, `Disposable` | `org.scijava.spi.*` (Phase 1). `Named` becomes read-only — `String name()` — with mutability a separate contract | planned | +| `Initializable` | `scijava-context` (Phase 2) — a lifecycle hook, not an SPI contract | planned | +| `Cancelable` | execution layer (Phase 3) — cancellation concerns a running thing | planned | +| `ItemIO` | `org.scijava.struct.ItemIO` | ported | +| `ItemVisibility` | input harvesting (Phase 4) | open | +| `Context`, `Contextual`, `AbstractContextual`, `NullContextException`, `NoSuchServiceException` | `org.scijava.context` (Phase 2) | planned | +| `Gateway`, `AbstractGateway`, `SciJava` | Phase 2, if retained at all | open | +| `Instantiable`, `InstantiableException` | subsumed by `Discovery` | planned | +| `BasicDetails`, `AbstractBasicDetails`, `UIDetails`, `AbstractUIDetails`, `MenuEntry`, `MenuPath` | plugin/module metadata (Phases 2–4) | 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` (Phase 1) | planned | +| `annotations.EclipseHelper` | none — m2e `.factorypath` covers it; directory indexing belongs in `scijava-maven-plugin` | dropped | +| `io.location`, `io.handle`, `io.nio`, `ByteBank` | `org.scijava.io3.*` (Phase 1) | planned | +| `io` (`IOPlugin`, `IOService`, `RecentFileService`) | `scijava-io3` (Phase 1), pending review of what is still used | open | +| `event`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | +| `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` | `org.scijava.context` + `org.scijava.index` + `Discoverer` (Phase 2) | planned | +| `service` | `org.scijava.context` — discovered via `ServiceLoader`; `Service` no longer extends `SciJavaPlugin` (Phase 2) | planned | +| `object`, `prefs`, `app` | `org.scijava.context` core services (Phase 2) | planned | +| `convert` | `scijava-convert3` (Phase 2) | planned | +| `module`, `module.process`, `command` | execution layer on `scijava-struct` (Phase 3) | planned | +| `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | +| `widget` | input harvesting on `scijava-struct`, with dynamic subgroups (Phase 4) | planned | +| `ui`, `menu` | minimal UI API; `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` dropped (Phase 4) | 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` | single CLI module (Phase 4) | planned | +| `startup` | standalone module (Phase 4) | planned | +| `welcome`, `download`, `text`, `options` | likely dropped | open | +| `minimaven`, `test` | out of scope | dropped | From 82cd7c81112322163bf22435c8772ce8c1dce036 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 11:13:11 -0500 Subject: [PATCH 02/59] scijava-ops-image: stop using SciJava Common utilities Switch the tests to the SciJava3 equivalents that already exist: - org.scijava.util.MersenneTwisterFast -> org.scijava.common3 (16 usages) - org.scijava.util.LongArray -> org.scijava.collections - org.scijava.util.ListUtils.first -> inlined; it is two lines, and ListUtils has no SciJava3 home yet The remaining scijava-common usage in these tests is io.location.FileLocation, which is required by SCIFIO's ImgOpener API, so it cannot go away until SCIFIO migrates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- .../java/org/scijava/ops/image/AbstractFeatureTest.java | 2 +- .../test/java/org/scijava/ops/image/copy/CopyIITest.java | 2 +- .../java/org/scijava/ops/image/copy/CopyImgTest.java | 2 +- .../java/org/scijava/ops/image/copy/CopyImgsTest.java | 2 +- .../java/org/scijava/ops/image/copy/CopyRAITest.java | 2 +- .../java/org/scijava/ops/image/create/CreateImgTest.java | 2 +- .../org/scijava/ops/image/create/CreateLabelingTest.java | 2 +- .../distancetransform/DefaultDistanceTransformTest.java | 2 +- .../image/distancetransform/DistanceTransform2DTest.java | 2 +- .../image/distancetransform/DistanceTransform3DTest.java | 2 +- .../ops/image/image/watershed/WatershedSeededTest.java | 2 +- .../ops/image/threshold/AbstractThresholdTest.java | 9 ++++++--- .../transform/concatenateView/ConcatenateViewTest.java | 2 +- .../transform/interpolateView/InterpolateViewTest.java | 2 +- .../image/transform/intervalView/IntervalViewTest.java | 2 +- .../ops/image/transform/permuteView/PermuteViewTest.java | 2 +- .../ops/image/transform/rasterView/RasterViewTest.java | 2 +- .../image/transform/subsampleView/SubsampleViewTest.java | 2 +- 18 files changed, 23 insertions(+), 20 deletions(-) diff --git a/scijava-ops-image/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java b/scijava-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/scijava-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/copy/CopyIITest.java b/scijava-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/scijava-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/CopyImgTest.java b/scijava-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/scijava-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/scijava-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/scijava-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/CopyRAITest.java b/scijava-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/scijava-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/create/CreateImgTest.java b/scijava-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/scijava-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/CreateLabelingTest.java b/scijava-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/scijava-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/image/distancetransform/DefaultDistanceTransformTest.java b/scijava-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/scijava-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/scijava-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/scijava-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/scijava-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/scijava-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/watershed/WatershedSeededTest.java b/scijava-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/scijava-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/threshold/AbstractThresholdTest.java b/scijava-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/scijava-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/transform/concatenateView/ConcatenateViewTest.java b/scijava-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/scijava-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/interpolateView/InterpolateViewTest.java b/scijava-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/scijava-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/scijava-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/scijava-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/permuteView/PermuteViewTest.java b/scijava-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/scijava-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/rasterView/RasterViewTest.java b/scijava-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/scijava-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/subsampleView/SubsampleViewTest.java b/scijava-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/scijava-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. From 71852f7793ab8708e1f3238e8673ec67d7612925 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 11:23:16 -0500 Subject: [PATCH 03/59] scijava-discovery: make Discoverer yield lazy descriptors Discoverer.discover now returns List> rather than List. A Discovery is a descriptor with three access levels of increasing cost: implClassName()/attrs() load nothing, type() loads the class, get() constructs the object. Discoverer.instances(Class) covers the common case of wanting the objects. This is what lets an index-backed Discoverer report what is available without loading classes -- the property Fiji's menu construction depends on, and which the old signature made impossible to express. Op YAML metadata and ServiceLoader providers both map onto the same shape. Note a JPMS constraint found while implementing this: ServiceLoader resolves `uses` declarations against its *caller's* module, so scijava-discovery cannot perform a lookup on another module's behalf. The lookup function is therefore supplied by the consuming module, via 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. scijava-discovery now depends on scijava-priority, so that discoveries can carry a priority without being constructed. This is a breaking API change, which the repository's current adoption level permits. All call sites are updated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 31 +- .../discovery/test/ServiceBasedAdder.java | 10 + .../discovery/test/DiscoveryLazinessTest.java | 80 ++++++ .../test/ServiceLoaderDiscovererTest.java | 4 +- scijava-discovery/pom.xml | 8 + .../src/main/java/module-info.java | 2 + .../org/scijava/discovery/Discoverer.java | 239 +++++++++------- .../java/org/scijava/discovery/Discovery.java | 264 ++++++++++++++++++ .../scijava/discovery/ManualDiscoverer.java | 10 +- .../types/DatasetTypeExtractorTest.java | 4 +- .../org/scijava/ops/api/OpEnvironment.java | 2 +- .../ops/engine/impl/DefaultOpEnvironment.java | 18 +- .../yaml/impl/YAMLOpInfoDiscoverer.java | 14 +- .../ops/engine/AbstractTestEnvironment.java | 2 +- .../ops/engine/impl/OpCachingTest.java | 2 +- ...ServiceLoaderDiscoveryIntegrationTest.java | 8 +- .../types/extract/DefaultTypeReifier.java | 2 +- .../types/extract/DefaultTypeReifierTest.java | 2 +- 18 files changed, 565 insertions(+), 137 deletions(-) create mode 100644 scijava-discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java create mode 100644 scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 08efe260d..b9199dab4 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -82,10 +82,10 @@ they need the corresponding SJC classes deprecated with pointers. Remaining SJC usage inside this repository: - `scijava-legacy` — intentional; it is the bridge. -- `scijava-ops-image` / `scijava-ops-flim` tests — `util.MersenneTwisterFast` - (16 usages), `util.LongArray`, `util.ListUtils`, `io.location.FileLocation`, - `Context`. Removable today (Phase 0). -- `scijava-ops-tutorial` — one import in main code. +- `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 @@ -130,6 +130,22 @@ All three mechanisms map cleanly: `ServiceLoader.stream()` supplies `@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 @@ -184,8 +200,10 @@ SLF4J, used directly, as `scijava-types` and `scijava-ops-engine` already do. - Remove SJC usage where an SJ3 replacement already exists: switch the `ops-image` / `ops-flim` tests to `common3.MersenneTwisterFast` and - `collections.LongArray`, replace `ListUtils`, fix the `ops-tutorial` import. - Afterwards only `scijava-legacy` and `scijava-code-api` depend on SJC. + `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. @@ -193,6 +211,7 @@ SLF4J, used directly, as `scijava-types` and `scijava-ops-engine` already do. `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 diff --git a/scijava-discovery-test/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java b/scijava-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/scijava-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/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java b/scijava-discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java new file mode 100644 index 000000000..1477a950a --- /dev/null +++ b/scijava-discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java @@ -0,0 +1,80 @@ +/*- + * #%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() { + 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/scijava-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/scijava-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/pom.xml b/scijava-discovery/pom.xml index 4902d833b..7a64c1139 100644 --- a/scijava-discovery/pom.xml +++ b/scijava-discovery/pom.xml @@ -86,6 +86,14 @@ SciJava developers. + + + org.scijava + scijava-priority + ${scijava-priority.version} + + + diff --git a/scijava-discovery/src/main/java/module-info.java b/scijava-discovery/src/main/java/module-info.java index 2d09d2221..a9bd79618 100644 --- a/scijava-discovery/src/main/java/module-info.java +++ b/scijava-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/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java b/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java index 5779ef3fa..42e3dd678 100644 --- a/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java +++ b/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java @@ -29,55 +29,84 @@ package org.scijava.discovery; -import java.lang.reflect.Type; -import java.util.*; +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; /** - * Discovers implementations of a given type. + * 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 some {@link Class} {@code c}. + * Discovers implementations of the given type. * - * @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} + * @param c the type of interest + * @return descriptors of the implementations found */ - List discover(Class c); + List> discover(Class c); /** - * Creates a {@link Discoverer} operating via {@link Function}. + * 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. *

- * 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. + * 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 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} + * @param func yields the providers of a given type + * @return a lazy discoverer wrapping that function */ - static Discoverer using(Function, ? extends Iterable> func) { + static Discoverer usingProviders( + final Function, Stream>> func) + { return new Discoverer() { @Override - public List discover(Class c) { - // If we can use c, look up the implementations + @SuppressWarnings("unchecked") + public List> discover(final Class c) { try { - var itr = (Iterable) func.apply((Class) c); - return StreamSupport.stream(itr.spliterator(), false).collect( - Collectors.toList()); + return ((Stream>) (Stream) // + func.apply((Class) c)) // + .map(p -> Discovery.of(p.type(), p)) // + .collect(Collectors.toList()); } catch (ClassCastException | ServiceConfigurationError e) { return Collections.emptyList(); @@ -87,61 +116,79 @@ public List discover(Class c) { } /** - * 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); - * } + * 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 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} + * @param func yields the implementations of a given type + * @return a discoverer wrapping that function */ - static List all( - Function, ? extends Iterable> func) + static Discoverer using( + final 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; + 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(); + } + } + }; } /** - * Accumulates multiple {@link Discoverer}s into one mega-{@code Discoverer} + * 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. + *

* - * @param discoverers the {@link Discoverer}s to be wrapped - * @return the mega-{@link Discoverer} + * @return the provided discoverers */ - static Discoverer union(Iterable 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(Class c) { - List list = new ArrayList<>(); + public List> discover(final Class c) { + final List> list = new ArrayList<>(); for (var discoverer : discoverers) { list.addAll(discoverer.discover(c)); } @@ -151,19 +198,18 @@ public List discover(Class c) { } /** - * Wraps up this {@code Discoverer} into a {@link Discoverer} that only - * discoverers classes {@code classes} + * Restricts this discoverer to the given types. * - * @param classes the {@link Class}es - * @return the wrapping + * @param classes the types to allow + * @return the restricted discoverer */ - default Discoverer onlyFor(Class... classes) { - var list = Arrays.asList(classes); - var d = this; + default Discoverer onlyFor(final Class... classes) { + var list = Arrays.asList(classes); + var d = this; return new Discoverer() { @Override - public List discover(Class c) { + public List> discover(final Class c) { if (list.contains(c)) return d.discover(c); return Collections.emptyList(); } @@ -171,19 +217,18 @@ public List discover(Class c) { } /** - * Wraps up this {@code Discoverer} into a {@link Discoverer} that only - * discoverers classes {@code classes} + * Blocks this discoverer from discovering the given types. * - * @param classes the {@link Class}es - * @return the wrapping + * @param classes the types to block + * @return the restricted discoverer */ - default Discoverer except(Class... classes) { - var list = Arrays.asList(classes); - var d = this; + default Discoverer except(final Class... classes) { + var list = Arrays.asList(classes); + var d = this; return new Discoverer() { @Override - public List discover(Class c) { + public List> discover(final Class c) { if (list.contains(c)) return Collections.emptyList(); return d.discover(c); } @@ -191,30 +236,24 @@ public List discover(Class c) { } /** - * Finds the maximum implementation of any {@link Comparable} {@code c}. + * Discovers the greatest implementation of the given comparable type. * - * @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} + * @param c the type of interest + * @return the greatest implementation found, if any */ - default > Optional discoverMax(Class c) { - var discoveries = discover(c); + default > Optional discoverMax(final Class c) { // NB: natural order sorts in ascending order - return discoveries.stream().max(Comparator.naturalOrder()); + return instances(c).stream().max(Comparator.naturalOrder()); } /** - * Finds the minimum implementation of any {@link Comparable} {@code c}. + * Discovers the least implementation of the given comparable type. * - * @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} + * @param c the type of interest + * @return the least implementation found, if any */ - default > Optional discoverMin(Class c) { - var discoveries = discover(c); + default > Optional discoverMin(final Class c) { // NB: natural order sorts in ascending order - return discoveries.stream().min(Comparator.naturalOrder()); + return instances(c).stream().min(Comparator.naturalOrder()); } } diff --git a/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java b/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java new file mode 100644 index 000000000..22bdc991f --- /dev/null +++ b/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java @@ -0,0 +1,264 @@ +/*- + * #%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.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 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() { + 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/scijava-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/scijava-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/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java b/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java index a49ed96a0..ab63755ec 100644 --- a/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java +++ b/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java @@ -54,10 +54,10 @@ public class DatasetTypeExtractorTest { @Test public void testDatasetTypeExtractor() { // Create a TypeReifier - // NB the use of Discoverer.all(ServiceLoader::load) finds no TypeExtractors + // NB the use of Discoverer.all(Discoverer.usingProviders(c -> ServiceLoader.load(c).stream())) 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)); + 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/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java b/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java index e565b0779..e395c75d0 100644 --- a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java +++ b/scijava-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!") // diff --git a/scijava-ops-engine/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java b/scijava-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/scijava-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/yaml/impl/YAMLOpInfoDiscoverer.java b/scijava-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/scijava-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/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java b/scijava-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/scijava-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/impl/OpCachingTest.java b/scijava-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/scijava-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/ServiceLoaderDiscoveryIntegrationTest.java b/scijava-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/scijava-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-types/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java b/scijava-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/scijava-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/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java b/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java index 3f2e854d4..4c27d18a6 100644 --- a/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java +++ b/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java @@ -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); } From 0771222ed854633f514cf0d3333683eaec700225 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 11:35:19 -0500 Subject: [PATCH 04/59] Pin scijava-discovery and scijava-priority versions Six components consumed scijava-discovery transitively without pinning its version property, so they resolved the released 1.0.0 artifact from the repository rather than the reactor's 1.0.1-SNAPSHOT. That was invisible while the API was unchanged, and became 1116 NoSuchMethodErrors as soon as it was not. Note that the "these versions must match across all component POMs" convention has no enforcement behind it: a version property that is merely absent silently pulls a released artifact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration.md | 2 +- scijava-ops-benchmarks/pom.xml | 2 ++ scijava-ops-ext-parser/pom.xml | 2 ++ scijava-ops-flim/pom.xml | 2 ++ scijava-ops-image/pom.xml | 1 + scijava-ops-opencv/pom.xml | 2 ++ scijava-ops-tutorial/pom.xml | 4 +++- 7 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 11fec92b7..e8710b410 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -37,8 +37,8 @@ A **dropped** row is a successful outcome, not a gap. | `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` | `org.scijava.common3` (Phase 1); `first` is one line, inline it | planned | | `LineOutputStream`, `ReadInto`, `StringMaker`, `Query` | none — inline as needed | dropped | -| `ListUtils` | `org.scijava.common3` (Phase 1) | planned | | `Manifest` | `org.scijava.meta.Manifest` | ported | | `MersenneTwisterFast` | `org.scijava.common3.MersenneTwisterFast` | ported | | `MirrorWebsite`, `TunePlayer`, `ReflectedUniverse`, `ReflectException` | none | dropped | diff --git a/scijava-ops-benchmarks/pom.xml b/scijava-ops-benchmarks/pom.xml index c81ce123e..dc1c3a3ba 100644 --- a/scijava-ops-benchmarks/pom.xml +++ b/scijava-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-ext-parser/pom.xml b/scijava-ops-ext-parser/pom.xml index b4772a1c0..d5beba111 100644 --- a/scijava-ops-ext-parser/pom.xml +++ b/scijava-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-flim/pom.xml b/scijava-ops-flim/pom.xml index 09b7e233e..b0354c6b0 100644 --- a/scijava-ops-flim/pom.xml +++ b/scijava-ops-flim/pom.xml @@ -97,12 +97,14 @@ 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 diff --git a/scijava-ops-image/pom.xml b/scijava-ops-image/pom.xml index 8a0492b94..7da8f79d1 100644 --- a/scijava-ops-image/pom.xml +++ b/scijava-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 diff --git a/scijava-ops-opencv/pom.xml b/scijava-ops-opencv/pom.xml index eae13d5b8..26ba77186 100644 --- a/scijava-ops-opencv/pom.xml +++ b/scijava-ops-opencv/pom.xml @@ -100,11 +100,13 @@ --add-reads org.scijava.ops.opencv=org.scijava.ops.api,net.imglib2 + 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 diff --git a/scijava-ops-tutorial/pom.xml b/scijava-ops-tutorial/pom.xml index 3a7a1a667..c225dff20 100644 --- a/scijava-ops-tutorial/pom.xml +++ b/scijava-ops-tutorial/pom.xml @@ -100,13 +100,15 @@ 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 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 From 4b8feffdff2e0a1d37ff2c7104212f8a30086bf7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 11:45:47 -0500 Subject: [PATCH 05/59] Pin every consumed SciJava artifact version Six components resolved released 1.0.0 artifacts instead of the reactor's snapshots, because these modules inherit from pom-scijava rather than this repository's aggregator POM: an absent version property does not fail, it silently falls back to the last released version in pom-scijava's dependency management. scijava-legacy, for instance, pulled released scijava-meta and scijava-struct 1.0.0, whose classes reference org.scijava.common3 interfaces. That only surfaces when an API changes, as a NoClassDefFoundError far from its cause. Also defines scijava-priority.version in scijava-discovery, which referenced the property without declaring it. Audited with dependency:list against the reactor versions; zero mismatches remain. Nothing enforces this convention -- an enforcer rule would be worth adding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- .../org/scijava/common3/GenericTyped.java | 42 ------------------- .../java/org/scijava/common3/Versioned.java | 41 ------------------ scijava-discovery-test/pom.xml | 1 + scijava-discovery/pom.xml | 3 ++ scijava-legacy/pom.xml | 4 ++ scijava-ops-flim/pom.xml | 5 +++ scijava-ops-image/pom.xml | 1 + scijava-ops-opencv/pom.xml | 7 ++++ scijava-ops-tutorial/pom.xml | 5 +++ 9 files changed, 26 insertions(+), 83 deletions(-) delete mode 100644 scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java delete mode 100644 scijava-common3/src/main/java/org/scijava/common3/Versioned.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java b/scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java deleted file mode 100644 index 2ae41be36..000000000 --- a/scijava-common3/src/main/java/org/scijava/common3/GenericTyped.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * #%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.lang.reflect.Type; - -/** - * An object which knows its generic type. - * - * @author Curtis Rueden - */ -public interface GenericTyped { - - Type type(); -} diff --git a/scijava-common3/src/main/java/org/scijava/common3/Versioned.java b/scijava-common3/src/main/java/org/scijava/common3/Versioned.java deleted file mode 100644 index ac0d77d63..000000000 --- a/scijava-common3/src/main/java/org/scijava/common3/Versioned.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * #%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; - -/** - * An object that knows its version. - * - * @author Curtis Rueden - */ -public interface Versioned { - - /** Gets the version of the object. */ - String version(); -} diff --git a/scijava-discovery-test/pom.xml b/scijava-discovery-test/pom.xml index ae8daba40..1dabf7ccb 100644 --- a/scijava-discovery-test/pom.xml +++ b/scijava-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/pom.xml b/scijava-discovery/pom.xml index 7a64c1139..389de7005 100644 --- a/scijava-discovery/pom.xml +++ b/scijava-discovery/pom.xml @@ -84,6 +84,9 @@ bsd_2 SciJava developers. + + + 1.0.1-SNAPSHOT diff --git a/scijava-legacy/pom.xml b/scijava-legacy/pom.xml index 120d28c43..ca67d329a 100644 --- a/scijava-legacy/pom.xml +++ b/scijava-legacy/pom.xml @@ -85,13 +85,17 @@ 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 diff --git a/scijava-ops-flim/pom.xml b/scijava-ops-flim/pom.xml index b0354c6b0..422be54d4 100644 --- a/scijava-ops-flim/pom.xml +++ b/scijava-ops-flim/pom.xml @@ -96,15 +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-image/pom.xml b/scijava-ops-image/pom.xml index 7da8f79d1..b1851f47b 100644 --- a/scijava-ops-image/pom.xml +++ b/scijava-ops-image/pom.xml @@ -258,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-opencv/pom.xml b/scijava-ops-opencv/pom.xml index 26ba77186..527270b0c 100644 --- a/scijava-ops-opencv/pom.xml +++ b/scijava-ops-opencv/pom.xml @@ -100,13 +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-tutorial/pom.xml b/scijava-ops-tutorial/pom.xml index c225dff20..9c1654068 100644 --- a/scijava-ops-tutorial/pom.xml +++ b/scijava-ops-tutorial/pom.xml @@ -100,9 +100,13 @@ 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 2.0.0-SNAPSHOT @@ -110,6 +114,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 From 9084574faec63bfc61ee9dd8642293e3edf4cd92 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 11:45:47 -0500 Subject: [PATCH 06/59] scijava-spi: add a module of framework-free interfaces A new dependency-free module for interfaces that any object can implement without knowing about a SciJava framework, so that code wanting only these contracts takes on the smallest possible dependency. Contains Versioned and GenericTyped, moved out of scijava-common3, plus Named, Identifiable and Disposable ported from SciJava Common. No deprecated aliases are left in scijava-common3, which the repository's current adoption level permits. Method names are modernized rather than preserved: getIdentifier() is now id(), and Disposable.dispose() is abstract rather than a no-op default, since a default that does nothing means implementing the interface proves nothing to callers. Named exposes name() only -- mutability is a separate contract, rather than a setter that throws UnsupportedOperationException and forces every caller to feature-detect. Deliberately omitted for now, pending a consumer: - Locatable, whose only real content was a default implementation requiring common3.Classes.location, which this module cannot depend on. - Typed, which has no consumer until the plugin layer exists, and whose Class type() would silently satisfy GenericTyped.type() as well, since Class implements Type. Both are cheap to add on demand and expensive to remove once published. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 30 +++- docs/migration.md | 8 +- pom.xml | 1 + scijava-meta/pom.xml | 6 + scijava-meta/src/main/java/module-info.java | 1 + .../main/java/org/scijava/meta/Manifest.java | 2 +- .../src/main/java/org/scijava/meta/POM.java | 2 +- scijava-ops-api/pom.xml | 6 + .../src/main/java/module-info.java | 1 + .../org/scijava/ops/api/OpEnvironment.java | 2 +- .../java/org/scijava/ops/api/OpInstance.java | 2 +- .../main/java/org/scijava/ops/api/RichOp.java | 2 +- scijava-ops-engine/pom.xml | 6 + .../src/main/java/module-info.java | 1 + .../ops/engine/impl/LambdaTypeBaker.java | 2 +- .../ops/engine/impl/LambdaTypeBakerTest.java | 2 +- .../ops/engine/matcher/OpWrappersTest.java | 2 +- scijava-spi/pom.xml | 130 ++++++++++++++++++ scijava-spi/src/main/java/module-info.java | 34 +++++ .../main/java/org/scijava/spi/Disposable.java | 44 ++++++ .../java/org/scijava/spi/GenericTyped.java | 42 ++++++ .../java/org/scijava/spi/Identifiable.java | 47 +++++++ .../src/main/java/org/scijava/spi/Named.java | 48 +++++++ .../main/java/org/scijava/spi/Versioned.java | 41 ++++++ scijava-types/pom.xml | 6 + scijava-types/src/main/java/module-info.java | 1 + .../src/main/java/org/scijava/types/Nil.java | 2 +- .../scijava/types/extract/TypeReifier.java | 2 +- .../test/java/org/scijava/types/NilTest.java | 2 +- .../types/extract/DefaultTypeReifierTest.java | 2 +- 30 files changed, 458 insertions(+), 19 deletions(-) create mode 100644 scijava-spi/pom.xml create mode 100644 scijava-spi/src/main/java/module-info.java create mode 100644 scijava-spi/src/main/java/org/scijava/spi/Disposable.java create mode 100644 scijava-spi/src/main/java/org/scijava/spi/GenericTyped.java create mode 100644 scijava-spi/src/main/java/org/scijava/spi/Identifiable.java create mode 100644 scijava-spi/src/main/java/org/scijava/spi/Named.java create mode 100644 scijava-spi/src/main/java/org/scijava/spi/Versioned.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index b9199dab4..b29bffd7d 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -61,6 +61,17 @@ The deliverable is not a reimplementation of SJC. It is: acceptable for a component in wind-down. 3. **`scijava-legacy` bridges the other way** (SJ3 → SJC), and is the only place 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 @@ -217,11 +228,11 @@ SLF4J, used directly, as `scijava-types` and `scijava-ops-engine` already do. No application context required by anything in this phase. -- **`scijava-spi`** (new): pure interfaces, no behavior, no dependencies — - `Versioned`, `GenericTyped`, `Named`, `Identifiable`, `Locatable`, `Typed`, - `Disposable`. `Versioned` and `GenericTyped` move here from `common3`, which - retains deprecated sub-interfaces so implementors stay source-compatible. - `common3` does **not** gain a transitive dependency on it. +- **`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 (cancellation concerns a running thing — it belongs with the execution @@ -231,6 +242,12 @@ No application context required by anything in this phase. - `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 @@ -393,4 +410,7 @@ independence and should not carry the `scijava-` prefix. the bridge content grows. - **`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 index e8710b410..6569256a7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -71,8 +71,12 @@ A **dropped** row is a successful outcome, not a gap. | SJC class | Replacement | Status | | --- | --- | --- | | `Priority`, `Prioritized` | `org.scijava.priority.*` | ported | -| `Versioned` | `org.scijava.spi.Versioned` (Phase 1; currently `common3`) | planned | -| `Named`, `Identifiable`, `Locatable`, `Typed`, `Disposable` | `org.scijava.spi.*` (Phase 1). `Named` becomes read-only — `String name()` — with mutability a separate contract | planned | +| `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` | execution layer (Phase 3) — cancellation concerns a running thing | planned | | `ItemIO` | `org.scijava.struct.ItemIO` | ported | diff --git a/pom.xml b/pom.xml index c86ef69cc..a0eaab775 100644 --- a/pom.xml +++ b/pom.xml @@ -75,6 +75,7 @@ scijava-ops-tutorial scijava-priority scijava-progress + scijava-spi scijava-struct scijava-testutil scijava-types diff --git a/scijava-meta/pom.xml b/scijava-meta/pom.xml index 0502e68f9..5278fbe2c 100644 --- a/scijava-meta/pom.xml +++ b/scijava-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/scijava-meta/src/main/java/module-info.java index 108acb6e5..a12983cc8 100644 --- a/scijava-meta/src/main/java/module-info.java +++ b/scijava-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/scijava-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/scijava-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/scijava-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/scijava-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-ops-api/pom.xml b/scijava-ops-api/pom.xml index e110a8466..7762c6812 100644 --- a/scijava-ops-api/pom.xml +++ b/scijava-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/scijava-ops-api/src/main/java/module-info.java index 996975ecc..9733260ec 100644 --- a/scijava-ops-api/src/main/java/module-info.java +++ b/scijava-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/OpEnvironment.java b/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java index e395c75d0..ac0d87223 100644 --- a/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java +++ b/scijava-ops-api/src/main/java/org/scijava/ops/api/OpEnvironment.java @@ -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/OpInstance.java b/scijava-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/scijava-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/RichOp.java b/scijava-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/scijava-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-engine/pom.xml b/scijava-ops-engine/pom.xml index 21c88198c..3079ce879 100644 --- a/scijava-ops-engine/pom.xml +++ b/scijava-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/main/java/module-info.java b/scijava-ops-engine/src/main/java/module-info.java index 3da250323..c0243778b 100644 --- a/scijava-ops-engine/src/main/java/module-info.java +++ b/scijava-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/impl/LambdaTypeBaker.java b/scijava-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/scijava-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/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java b/scijava-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/scijava-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/matcher/OpWrappersTest.java b/scijava-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/scijava-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-spi/pom.xml b/scijava-spi/pom.xml new file mode 100644 index 000000000..4889cf69d --- /dev/null +++ b/scijava-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/scijava-spi/src/main/java/module-info.java b/scijava-spi/src/main/java/module-info.java new file mode 100644 index 000000000..5f4fbd6cf --- /dev/null +++ b/scijava-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/scijava-spi/src/main/java/org/scijava/spi/Disposable.java b/scijava-spi/src/main/java/org/scijava/spi/Disposable.java new file mode 100644 index 000000000..363b50169 --- /dev/null +++ b/scijava-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-spi/src/main/java/org/scijava/spi/GenericTyped.java b/scijava-spi/src/main/java/org/scijava/spi/GenericTyped.java new file mode 100644 index 000000000..782d51bbb --- /dev/null +++ b/scijava-spi/src/main/java/org/scijava/spi/GenericTyped.java @@ -0,0 +1,42 @@ +/* + * #%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; + +import java.lang.reflect.Type; + +/** + * An object which knows its generic type. + * + * @author Curtis Rueden + */ +public interface GenericTyped { + + Type type(); +} diff --git a/scijava-spi/src/main/java/org/scijava/spi/Identifiable.java b/scijava-spi/src/main/java/org/scijava/spi/Identifiable.java new file mode 100644 index 000000000..7e9d0953f --- /dev/null +++ b/scijava-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/scijava-spi/src/main/java/org/scijava/spi/Named.java b/scijava-spi/src/main/java/org/scijava/spi/Named.java new file mode 100644 index 000000000..ac12d4f6a --- /dev/null +++ b/scijava-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-spi/src/main/java/org/scijava/spi/Versioned.java b/scijava-spi/src/main/java/org/scijava/spi/Versioned.java new file mode 100644 index 000000000..7b133eb58 --- /dev/null +++ b/scijava-spi/src/main/java/org/scijava/spi/Versioned.java @@ -0,0 +1,41 @@ +/* + * #%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 version. + * + * @author Curtis Rueden + */ +public interface Versioned { + + /** Gets the version of the object. */ + String version(); +} diff --git a/scijava-types/pom.xml b/scijava-types/pom.xml index e2ef67513..e9ba8400a 100644 --- a/scijava-types/pom.xml +++ b/scijava-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/scijava-types/src/main/java/module-info.java index 31f9a0b9f..0f7da923d 100644 --- a/scijava-types/src/main/java/module-info.java +++ b/scijava-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/scijava-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/scijava-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/extract/TypeReifier.java b/scijava-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/scijava-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/test/java/org/scijava/types/NilTest.java b/scijava-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/scijava-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/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java index 4c27d18a6..9ac1226d0 100644 --- a/scijava-types/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java +++ b/scijava-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} From 9a5b15eb6664b80d9df55ef12c3ca5b361ccd9c2 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 12:02:18 -0500 Subject: [PATCH 07/59] scijava-common3: absorb general utilities from SciJava Common Adds four classes, porting only what earns its place and recording the rest as dropped in docs/migration.md: - Numbers (from NumberUtils, plus ArrayUtils.safeMultiply32/64) - Strings (from StringUtils: padStart, padEnd, isNullOrEmpty, splitUnquoted) - Digests (from DigestUtils) - Processes (from ProcessUtils, reimplemented on ProcessBuilder) Decisions were driven by counting real usages across the scijava and imagej repositories: - ArrayUtils is dropped apart from safeMultiply32/64 (11 usages, and worth keeping as a readable wrapper for Math.multiplyExact). Its 18 contains/indexOf methods have 3 usages between them and are a loop or a stream in the standard library. - ListUtils (first: one line; string: StringJoiner) and UnitUtils (one usage ecosystem-wide) are dropped. - Bytes is deferred to scijava-io3, its natural consumer. - StringUtils.sanitize, sanitizeDouble, stripNulls and samePrefix have no usages anywhere and are dropped. Behavioral changes worth noting, both recorded in the migration table: - Numbers.toNumber no longer consults a conversion framework, since scijava-common3 must stay dependency-free; it handles numbers and strings only. - Digests.digest throws IllegalArgumentException for an unknown algorithm rather than returning null, which in SciJava Common surfaced as a NullPointerException somewhere else entirely. The best* family is therefore plain SHA-1, which every Java platform guarantees. Tests are ported from SciJava Common to JUnit 5 and extended. The Processes tests cover what its reimplementation could plausibly break: closing the process's stdin once input is exhausted, not closing the caller's streams, a multi-byte character split across two reads, a non-zero exit status, and interruption. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration.md | 16 +- .../java/org/scijava/common3/Digests.java | 163 ++++++++++++++ .../java/org/scijava/common3/Numbers.java | 198 ++++++++++++++++++ .../java/org/scijava/common3/Processes.java | 184 ++++++++++++++++ .../java/org/scijava/common3/Strings.java | 119 +++++++++++ .../java/org/scijava/common3/DigestsTest.java | 137 ++++++++++++ .../java/org/scijava/common3/NumbersTest.java | 136 ++++++++++++ .../org/scijava/common3/ProcessesTest.java | 118 +++++++++++ .../java/org/scijava/common3/StringsTest.java | 86 ++++++++ 9 files changed, 1149 insertions(+), 8 deletions(-) create mode 100644 scijava-common3/src/main/java/org/scijava/common3/Digests.java create mode 100644 scijava-common3/src/main/java/org/scijava/common3/Numbers.java create mode 100644 scijava-common3/src/main/java/org/scijava/common3/Processes.java create mode 100644 scijava-common3/src/main/java/org/scijava/common3/Strings.java create mode 100644 scijava-common3/src/test/java/org/scijava/common3/DigestsTest.java create mode 100644 scijava-common3/src/test/java/org/scijava/common3/NumbersTest.java create mode 100644 scijava-common3/src/test/java/org/scijava/common3/ProcessesTest.java create mode 100644 scijava-common3/src/test/java/org/scijava/common3/StringsTest.java diff --git a/docs/migration.md b/docs/migration.md index 6569256a7..84f293cc9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -21,9 +21,9 @@ A **dropped** row is a successful outcome, not a gap. | --- | --- | --- | | `AbstractPrimitiveArray` | `org.scijava.collections.AbstractPrimitiveArray` | ported | | `AppUtils` | `org.scijava.common3.Apps` | ported | -| `ArrayUtils` | `org.scijava.common3` (Phase 1) | planned | +| `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.common3` (Phase 1) | planned | +| `Bytes` | deferred to `scijava-io3`, its natural consumer; `java.nio.ByteBuffer` covers casual use | open | | `CheckSezpoz` | none | dropped | | `ClassUtils` | `org.scijava.common3.Classes` (mostly already deprecated in SJC) | ported | | `ColorRGB`, `ColorRGBA`, `Colors` | UI layer (Phase 4) | open | @@ -31,29 +31,29 @@ A **dropped** row is a successful outcome, not a gap. | `ConversionUtils` | `scijava-convert3` (Phase 2); already deprecated in SJC | planned | | `DebugUtils` | `org.scijava.common3.Threads` | ported | | `DefaultTreeNode`, `TreeNode` | on hold — only `imagej-common` uses it; may move there | open | -| `DigestUtils` | `org.scijava.common3` (Phase 1) | planned | +| `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` | `org.scijava.common3` (non-URL parts, Phase 1); URL parts in `common3.URLs` | planned | | `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` | `org.scijava.common3` (Phase 1); `first` is one line, inline it | planned | +| `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` (Phase 1) | planned | +| `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` | `scijava-context` prefs service (Phase 2); already deprecated in SJC | planned | -| `ProcessUtils` | `org.scijava.common3` (Phase 1) | planned | +| `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` (Phase 1) | planned | +| `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` | `org.scijava.common3` (Phase 1) | planned | +| `UnitUtils` | dropped — one usage ecosystem-wide; add it back on demand | dropped | | `VersionUtils` | `org.scijava.meta.Versions` | ported | | `XML` | `org.scijava.meta.XML` | ported | diff --git a/scijava-common3/src/main/java/org/scijava/common3/Digests.java b/scijava-common3/src/main/java/org/scijava/common3/Digests.java new file mode 100644 index 000000000..9d389f4d1 --- /dev/null +++ b/scijava-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/scijava-common3/src/main/java/org/scijava/common3/Numbers.java b/scijava-common3/src/main/java/org/scijava/common3/Numbers.java new file mode 100644 index 000000000..f152645c5 --- /dev/null +++ b/scijava-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/Processes.java b/scijava-common3/src/main/java/org/scijava/common3/Processes.java new file mode 100644 index 000000000..454a1b750 --- /dev/null +++ b/scijava-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/scijava-common3/src/main/java/org/scijava/common3/Strings.java b/scijava-common3/src/main/java/org/scijava/common3/Strings.java new file mode 100644 index 000000000..38a7eba4d --- /dev/null +++ b/scijava-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/test/java/org/scijava/common3/DigestsTest.java b/scijava-common3/src/test/java/org/scijava/common3/DigestsTest.java new file mode 100644 index 000000000..5718af950 --- /dev/null +++ b/scijava-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/scijava-common3/src/test/java/org/scijava/common3/NumbersTest.java b/scijava-common3/src/test/java/org/scijava/common3/NumbersTest.java new file mode 100644 index 000000000..cac56ae75 --- /dev/null +++ b/scijava-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/scijava-common3/src/test/java/org/scijava/common3/ProcessesTest.java b/scijava-common3/src/test/java/org/scijava/common3/ProcessesTest.java new file mode 100644 index 000000000..b22942984 --- /dev/null +++ b/scijava-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/scijava-common3/src/test/java/org/scijava/common3/StringsTest.java b/scijava-common3/src/test/java/org/scijava/common3/StringsTest.java new file mode 100644 index 000000000..5a7dbb4cc --- /dev/null +++ b/scijava-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,,", ",")); + } +} From 24ba06b0b40801ef5b122f7aa122c02b309db969 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 12:04:23 -0500 Subject: [PATCH 08/59] scijava-common3: add FilePaths, from SciJava Common's FileUtils FileUtils splits cleanly along a line drawn by java.nio.file: its filesystem half is now standard library, while its filename logic -- notably versioned JAR names, which Fiji's updater depends on -- has no replacement anywhere. FilePaths therefore carries normalizeSeparators (was getPath), extension, stripVersion, allVersions, matchVersionedFilename, and deleteRecursively, which java.nio.file still lacks. It is named FilePaths rather than Files because any implementation would import java.nio.file.Files, and a clashing simple name in a foundational utility module is a lasting annoyance. Dropped, with replacements recorded in docs/migration.md: readFile, writeFile, createTemporaryDirectory and getModifiedTime, all covered by java.nio.file.Files; and shortenPath and limitPath, whose only usages anywhere are inside SciJava Common itself. findResources is left for scijava-index, which needs classpath resource scanning anyway. Two deliberate behavioral changes: - deleteRecursively now deletes a plain file passed to it. SciJava Common returned true without deleting anything, since listFiles() returns null for a non-directory. - It is implemented with Files.walk in reverse order rather than recursion, so it no longer recurses into symlinked directories. Tests are ported from SciJava Common, keeping the full versioned-filename corpus (jogamp, jinput and ffmpeg native-binary naming), which is where the real complexity lives. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration.md | 3 +- .../java/org/scijava/common3/FilePaths.java | 188 ++++++++++++++++++ .../org/scijava/common3/FilePathsTest.java | 163 +++++++++++++++ 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 scijava-common3/src/main/java/org/scijava/common3/FilePaths.java create mode 100644 scijava-common3/src/test/java/org/scijava/common3/FilePathsTest.java diff --git a/docs/migration.md b/docs/migration.md index 84f293cc9..d94dcaccd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -32,7 +32,8 @@ A **dropped** row is a successful outcome, not a gap. | `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` | `org.scijava.common3` (non-URL parts, Phase 1); URL parts in `common3.URLs` | planned | +| `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 | diff --git a/scijava-common3/src/main/java/org/scijava/common3/FilePaths.java b/scijava-common3/src/main/java/org/scijava/common3/FilePaths.java new file mode 100644 index 000000000..5996ce053 --- /dev/null +++ b/scijava-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/test/java/org/scijava/common3/FilePathsTest.java b/scijava-common3/src/test/java/org/scijava/common3/FilePathsTest.java new file mode 100644 index 000000000..6f312b202 --- /dev/null +++ b/scijava-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"))); + } +} From 7d734826ed75c9f3b3f203a1da164a1001267624 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 13:34:15 -0500 Subject: [PATCH 09/59] scijava-index: port annotation indexing, with a lazy Discoverer Ports SciJava Common's org.scijava.annotations as org.scijava.index, and adds IndexDiscoverer, which is what the Discovery refactor was for: an index records each implementation's class name and annotation values, so filtering, sorting and display happen on metadata alone, and only type() or get() touches a class. The index format (META-INF/json/) is deliberately unchanged, so an index written by either SciJava Common or SciJava3 is readable by the other. That keeps a jar built against one usable with the other during the migration. IndexDiscoverer stays annotation-agnostic: which type an indexed item provides is supplied as a function, since it must be answerable from metadata alone. 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. Two tests cover the properties that matter: - Discovery never asks the class loader for the implementation class. This needs a class loader that records what it is asked for; a static flag on the implementation cannot show it, because reading the flag would itself load the class. The flag test remains, but proves only that nothing was constructed, and is named accordingly. - The annotation processor works through javac, with this component on the processor path and nothing else configured -- the path a downstream project actually takes. The test asserts the index is written with its metadata and is then discoverable lazily. Not ported: EclipseHelper (m2e's .factorypath generation covers it), AnnotationCombiner (build tooling, belongs in scijava-maven-plugin), and the legacy reader for the pre-2013 META-INF/annotations format. DirectoryIndexer and ByteCodeAnalyzer are kept: this component needs them to index its own test classes, and a Maven plugin needs them for the fallback indexing path. This component sets maven.compiler.proc to none for itself, because javac reads META-INF/services before the processor class it names exists. SciJava Common has the same constraint and solves it the same way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 21 +- docs/migration.md | 6 +- pom.xml | 1 + scijava-index/pom.xml | 153 ++++++++ scijava-index/src/main/java/module-info.java | 41 ++ .../scijava/index/AbstractIndexWriter.java | 352 ++++++++++++++++++ .../scijava/index/AnnotationProcessor.java | 296 +++++++++++++++ .../org/scijava/index/ByteCodeAnalyzer.java | 352 ++++++++++++++++++ .../org/scijava/index/DirectoryIndexer.java | 187 ++++++++++ .../main/java/org/scijava/index/Index.java | 188 ++++++++++ .../org/scijava/index/IndexDiscoverer.java | 129 +++++++ .../java/org/scijava/index/IndexItem.java | 242 ++++++++++++ .../java/org/scijava/index/IndexReader.java | 222 +++++++++++ .../java/org/scijava/index/Indexable.java | 48 +++ .../javax.annotation.processing.Processor | 1 + .../index/AnnotationProcessorTest.java | 132 +++++++ .../test/java/org/scijava/index/Circle.java | 40 ++ .../test/java/org/scijava/index/Color.java | 35 ++ .../scijava/index/IndexDiscovererTest.java | 193 ++++++++++ .../src/test/java/org/scijava/index/Red.java | 40 ++ .../test/java/org/scijava/index/Shape.java | 35 ++ .../test/java/org/scijava/index/Square.java | 49 +++ .../test/java/org/scijava/index/Widget.java | 50 +++ 23 files changed, 2809 insertions(+), 4 deletions(-) create mode 100644 scijava-index/pom.xml create mode 100644 scijava-index/src/main/java/module-info.java create mode 100644 scijava-index/src/main/java/org/scijava/index/AbstractIndexWriter.java create mode 100644 scijava-index/src/main/java/org/scijava/index/AnnotationProcessor.java create mode 100644 scijava-index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java create mode 100644 scijava-index/src/main/java/org/scijava/index/DirectoryIndexer.java create mode 100644 scijava-index/src/main/java/org/scijava/index/Index.java create mode 100644 scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java create mode 100644 scijava-index/src/main/java/org/scijava/index/IndexItem.java create mode 100644 scijava-index/src/main/java/org/scijava/index/IndexReader.java create mode 100644 scijava-index/src/main/java/org/scijava/index/Indexable.java create mode 100644 scijava-index/src/main/resources/META-INF/services/javax.annotation.processing.Processor create mode 100644 scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java create mode 100644 scijava-index/src/test/java/org/scijava/index/Circle.java create mode 100644 scijava-index/src/test/java/org/scijava/index/Color.java create mode 100644 scijava-index/src/test/java/org/scijava/index/IndexDiscovererTest.java create mode 100644 scijava-index/src/test/java/org/scijava/index/Red.java create mode 100644 scijava-index/src/test/java/org/scijava/index/Shape.java create mode 100644 scijava-index/src/test/java/org/scijava/index/Square.java create mode 100644 scijava-index/src/test/java/org/scijava/index/Widget.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index b29bffd7d..deb7219ee 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -253,8 +253,25 @@ No application context required by anything in this phase. `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 a `Discoverer` backed by the - index. This is what supplies plugin metadata without loading classes. + 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`. Handles and resolvers are found via `Discoverer` + `Priority`. The external `scijava-io-http` diff --git a/docs/migration.md b/docs/migration.md index d94dcaccd..2ffc53d06 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -93,8 +93,10 @@ A **dropped** row is a successful outcome, not a gap. | SJC package | Replacement | Status | | --- | --- | --- | -| `annotations` | `org.scijava.index` (Phase 1) | planned | -| `annotations.EclipseHelper` | none — m2e `.factorypath` covers it; directory indexing belongs in `scijava-maven-plugin` | dropped | +| `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`, `io.handle`, `io.nio`, `ByteBank` | `org.scijava.io3.*` (Phase 1) | planned | | `io` (`IOPlugin`, `IOService`, `RecentFileService`) | `scijava-io3` (Phase 1), pending review of what is still used | open | | `event`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | diff --git a/pom.xml b/pom.xml index a0eaab775..2de9fd76a 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,7 @@ scijava-discovery scijava-discovery-test scijava-function + scijava-index scijava-legacy scijava-meta scijava-ops-api diff --git a/scijava-index/pom.xml b/scijava-index/pom.xml new file mode 100644 index 000000000..b1a56607b --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/module-info.java b/scijava-index/src/main/java/module-info.java new file mode 100644 index 000000000..15a6c51e7 --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/AbstractIndexWriter.java b/scijava-index/src/main/java/org/scijava/index/AbstractIndexWriter.java new file mode 100644 index 000000000..0e66dd2a1 --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/AnnotationProcessor.java b/scijava-index/src/main/java/org/scijava/index/AnnotationProcessor.java new file mode 100644 index 000000000..5ee1d6c3e --- /dev/null +++ b/scijava-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 {@link org.scijava.plugin.Plugin} annotation + * enables the {@link 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/scijava-index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java b/scijava-index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java new file mode 100644 index 000000000..9972206e5 --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/DirectoryIndexer.java b/scijava-index/src/main/java/org/scijava/index/DirectoryIndexer.java new file mode 100644 index 000000000..5c3a89d3f --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/Index.java b/scijava-index/src/main/java/org/scijava/index/Index.java new file mode 100644 index 000000000..1ecdd24ea --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java b/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java new file mode 100644 index 000000000..6542431b5 --- /dev/null +++ b/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java @@ -0,0 +1,129 @@ +/* + * #%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; + + /** + * 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); + } + + /** + * 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 = annotation; + this.typeNameOf = typeNameOf; + this.attrsOf = attrsOf; + this.priorityOf = priorityOf; + this.classLoader = classLoader; + } + + @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; + discoveries.add(Discovery.of(item.className(), c, loader, // + attrsOf.apply(item), priorityOf.applyAsDouble(item))); + } + return discoveries; + } +} diff --git a/scijava-index/src/main/java/org/scijava/index/IndexItem.java b/scijava-index/src/main/java/org/scijava/index/IndexItem.java new file mode 100644 index 000000000..509857cf8 --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/IndexReader.java b/scijava-index/src/main/java/org/scijava/index/IndexReader.java new file mode 100644 index 000000000..2d660412f --- /dev/null +++ b/scijava-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/scijava-index/src/main/java/org/scijava/index/Indexable.java b/scijava-index/src/main/java/org/scijava/index/Indexable.java new file mode 100644 index 000000000..d638fb6d0 --- /dev/null +++ b/scijava-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/scijava-index/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/scijava-index/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 000000000..839ead7a6 --- /dev/null +++ b/scijava-index/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +org.scijava.index.AnnotationProcessor diff --git a/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java b/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java new file mode 100644 index 000000000..41335709a --- /dev/null +++ b/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java @@ -0,0 +1,132 @@ +/* + * #%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); + } + + /** 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/scijava-index/src/test/java/org/scijava/index/Circle.java b/scijava-index/src/test/java/org/scijava/index/Circle.java new file mode 100644 index 000000000..ef6c42919 --- /dev/null +++ b/scijava-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/scijava-index/src/test/java/org/scijava/index/Color.java b/scijava-index/src/test/java/org/scijava/index/Color.java new file mode 100644 index 000000000..90382ad15 --- /dev/null +++ b/scijava-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/scijava-index/src/test/java/org/scijava/index/IndexDiscovererTest.java b/scijava-index/src/test/java/org/scijava/index/IndexDiscovererTest.java new file mode 100644 index 000000000..7b2a3aca5 --- /dev/null +++ b/scijava-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/scijava-index/src/test/java/org/scijava/index/Red.java b/scijava-index/src/test/java/org/scijava/index/Red.java new file mode 100644 index 000000000..31bf83408 --- /dev/null +++ b/scijava-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/scijava-index/src/test/java/org/scijava/index/Shape.java b/scijava-index/src/test/java/org/scijava/index/Shape.java new file mode 100644 index 000000000..c9b4d592a --- /dev/null +++ b/scijava-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/scijava-index/src/test/java/org/scijava/index/Square.java b/scijava-index/src/test/java/org/scijava/index/Square.java new file mode 100644 index 000000000..6d9d3ceb1 --- /dev/null +++ b/scijava-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/scijava-index/src/test/java/org/scijava/index/Widget.java b/scijava-index/src/test/java/org/scijava/index/Widget.java new file mode 100644 index 000000000..b213f1c53 --- /dev/null +++ b/scijava-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; +} From 313843fd7b1c8dcb8deb9674bf19f698ad8aae4a Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 14:07:34 -0500 Subject: [PATCH 10/59] docs: correct the Phase 2 injection design Constructor injection was the wrong call, for the reasons raised in review: it publishes every dependency in a signature, so changing an internal dependency becomes an API change and a binary compatibility break unless old constructors are kept forever. It also cannot serve the ServiceLoader tier, which requires a public no-arg constructor or a static provider() factory. The premise behind it was also wrong. I had assumed that letting the container instantiate a plugin means exporting its package, which would indeed break encapsulation -- callers could cast to implementations and call non-API methods. But `opens` is not `exports`: a qualified `opens to ` grants deep reflective access at runtime while leaving the package invisible at compile time. Measured with a three-module test project on JDK 21, rather than assumed: - ServiceLoader finds a provider declared with `provides` in a package that is neither exported nor opened. - Reflective newInstance on such a package fails with IllegalAccessException; with a qualified `opens` it succeeds. - setAccessible injection into a private field works with that opens. - An ordinary module importing a class from the opened but unexported package fails to compile: "package ... is declared in module ..., which does not export it". - A service with a private constructor and a public static provider() factory works; ServiceLoader prefers the factory. So declarative field injection survives JPMS intact, encapsulation is preserved, and the cost to a plugin module is one `opens` line. The claim that we must give up private-field injection is removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 60 ++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index deb7219ee..c0ce3b4ef 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -30,9 +30,11 @@ The deliverable is not a reimplementation of SJC. It is: - **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, - plugin classes public in exported packages, constructor injection rather - than private-field reflection, resources under `META-INF/`. The one thing we - give up is `setAccessible`-based injection into private fields. + 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 @@ -188,17 +190,32 @@ elsewhere too). ### JPMS specifics -- Index resources are safe: `META-INF/json/...` is not a valid package name, - so it stays readable via `ClassLoader.getResources` from named modules. -- Instantiating a public class with a public no-arg constructor in an - **exported** package needs no `opens`. -- `setAccessible` on private fields — how SJC injects `@Parameter` - (`Context.java:510`, `ServiceHelper.java:317`) — **does** require `opens`. - This is the one thing forcing every plugin module to open itself. +These were measured, not reasoned about, with a three-module test project +(`javac --module-source-path`, run on the module path, JDK 21): -Therefore the SJ3 context uses **constructor injection** (or an explicit -`initialize(Context)`), so that `exports` suffices and `@Plugin` coexists with -JPMS. +| 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 | + +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. + +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; +``` ### Logging @@ -296,10 +313,19 @@ No application context required by anything in this phase. ### Phase 2 — The application context - **`scijava-context`** (new, `org.scijava.context`): services with a - lifecycle and **constructor-based** dependency injection; plugin metadata - from `scijava-index`; instantiation via `Discoverer`; ordering via - `scijava-priority`. Plus the core services: object registry, prefs, thread, - app/status. + 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. + - **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. From 790ec74d3906e9bd45784bc212892638795da271 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 14:15:24 -0500 Subject: [PATCH 11/59] scijava-io3: port locations, standalone on plain ServiceLoader Ports org.scijava.io.location as org.scijava.io3.location, plus ByteBank and ByteArrayByteBank, which BytesLocation needs. This component is deliberately standalone. It is broadly useful on its own -- to Bio-Formats, for instance -- so its only dependency is scijava-collections, and resolvers are discovered with plain ServiceLoader rather than through Discoverer or a container. Nothing here requires buying into a framework. Plain ServiceLoader is viable precisely here, even though a generic discovery facade cannot use it: the constraint is that ServiceLoader resolves `uses` against its caller's module, which only hurts when discovering arbitrary types on another module's behalf. This component knows its own service type, so it declares `uses` in its own module-info.java and is self-contained. Consequent API changes, recorded in docs/migration.md: - LocationService and DefaultLocationService become Locations, which can also be constructed with an explicit resolver list for embedding and testing. - LocationResolver no longer extends HandlerPlugin, and carries its own priority() as a plain double rather than depending on scijava-priority. The Priority constants remain compatible with it. - URILocation had an injected LogService that nothing used; it is gone, and its query decoding now uses the charset-typed URLDecoder overload instead of catching an impossible UnsupportedEncodingException. Tests cover discovery without a container, priority ordering deciding between two resolvers that support the same scheme, and the reason the string overload exists: a Windows path is not a valid URI, so it must fall back to being read as a file path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 20 +- docs/migration.md | 6 +- pom.xml | 1 + scijava-io3/pom.xml | 143 ++++++++++++ scijava-io3/src/main/java/module-info.java | 42 ++++ .../org/scijava/io3/ByteArrayByteBank.java | 154 +++++++++++++ .../main/java/org/scijava/io3/ByteBank.java | 209 ++++++++++++++++++ .../io3/location/AbstractLocation.java | 68 ++++++ .../location/AbstractLocationResolver.java | 57 +++++ .../io3/location/AbstractRemoteLocation.java | 55 +++++ .../io3/location/BrowsableLocation.java | 100 +++++++++ .../scijava/io3/location/BytesLocation.java | 183 +++++++++++++++ .../scijava/io3/location/DummyLocation.java | 39 ++++ .../scijava/io3/location/FileLocation.java | 127 +++++++++++ .../io3/location/FileLocationResolver.java | 49 ++++ .../org/scijava/io3/location/Location.java | 86 +++++++ .../io3/location/LocationResolver.java | 80 +++++++ .../org/scijava/io3/location/Locations.java | 127 +++++++++++ .../scijava/io3/location/RemoteLocation.java | 53 +++++ .../org/scijava/io3/location/URILocation.java | 114 ++++++++++ .../org/scijava/io3/location/URLLocation.java | 73 ++++++ .../org.scijava.io3.location.LocationResolver | 1 + .../scijava/io3/location/LocationsTest.java | 124 +++++++++++ 23 files changed, 1906 insertions(+), 5 deletions(-) create mode 100644 scijava-io3/pom.xml create mode 100644 scijava-io3/src/main/java/module-info.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/ByteBank.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/BytesLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/DummyLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/FileLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/Location.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/LocationResolver.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/Locations.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/RemoteLocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/URILocation.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/location/URLLocation.java create mode 100644 scijava-io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver create mode 100644 scijava-io3/src/test/java/org/scijava/io3/location/LocationsTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index c0ce3b4ef..a85924f88 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -290,9 +290,23 @@ No application context required by anything in this phase. 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`. Handles and resolvers - are found via `Discoverer` + `Priority`. The external `scijava-io-http` - folds in later as `org.scijava.io3.http`. + `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. - **`scijava-events`** (new, `org.scijava.events`): a clean-room event bus, no context, designed for typed topics and weak-reference subscribers. Not a port of the bushe fork — rewriting removes the attribution obligation. The diff --git a/docs/migration.md b/docs/migration.md index 2ffc53d06..bee39ba44 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -23,7 +23,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | deferred to `scijava-io3`, its natural consumer; `java.nio.ByteBuffer` covers casual use | open | +| `Bytes` | `scijava-io3`, alongside the handles that need it (Phase 1, in progress) | planned | | `CheckSezpoz` | none | dropped | | `ClassUtils` | `org.scijava.common3.Classes` (mostly already deprecated in SJC) | ported | | `ColorRGB`, `ColorRGBA`, `Colors` | UI layer (Phase 4) | open | @@ -97,7 +97,9 @@ A **dropped** row is a successful outcome, not a gap. | `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`, `io.handle`, `io.nio`, `ByteBank` | `org.scijava.io3.*` (Phase 1) | planned | +| `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`, `io.nio` | `org.scijava.io3.handle` (Phase 1, in progress) | planned | | `io` (`IOPlugin`, `IOService`, `RecentFileService`) | `scijava-io3` (Phase 1), pending review of what is still used | open | | `event`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | | `parse` | Parsington (`org.scijava.parsington`) | planned | diff --git a/pom.xml b/pom.xml index 2de9fd76a..90f2f3dd6 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,7 @@ scijava-discovery-test scijava-function scijava-index + scijava-io3 scijava-legacy scijava-meta scijava-ops-api diff --git a/scijava-io3/pom.xml b/scijava-io3/pom.xml new file mode 100644 index 000000000..9bcdffad6 --- /dev/null +++ b/scijava-io3/pom.xml @@ -0,0 +1,143 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-io3 + 1.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/scijava-io3/src/main/java/module-info.java b/scijava-io3/src/main/java/module-info.java new file mode 100644 index 000000000..ede880683 --- /dev/null +++ b/scijava-io3/src/main/java/module-info.java @@ -0,0 +1,42 @@ +/* + * #%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.location; + + requires org.scijava.collections; + + uses org.scijava.io3.location.LocationResolver; + + provides org.scijava.io3.location.LocationResolver with + org.scijava.io3.location.FileLocationResolver; + +} diff --git a/scijava-io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java b/scijava-io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java new file mode 100644 index 000000000..8e17b8d25 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/ByteBank.java b/scijava-io3/src/main/java/org/scijava/io3/ByteBank.java new file mode 100644 index 000000000..ff6288fb2 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocation.java new file mode 100644 index 000000000..1fb52c2d1 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java b/scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java new file mode 100644 index 000000000..8362c142c --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java new file mode 100644 index 000000000..92cc7f99e --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java new file mode 100644 index 000000000..2b1af7190 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/BytesLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/BytesLocation.java new file mode 100644 index 000000000..c89bd8e89 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/DummyLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/DummyLocation.java new file mode 100644 index 000000000..1b15fc208 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/FileLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/FileLocation.java new file mode 100644 index 000000000..a0094a0d8 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java b/scijava-io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java new file mode 100644 index 000000000..33ab773a9 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/Location.java b/scijava-io3/src/main/java/org/scijava/io3/location/Location.java new file mode 100644 index 000000000..34de1b793 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/LocationResolver.java b/scijava-io3/src/main/java/org/scijava/io3/location/LocationResolver.java new file mode 100644 index 000000000..67eedff6e --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/Locations.java b/scijava-io3/src/main/java/org/scijava/io3/location/Locations.java new file mode 100644 index 000000000..6a28b64fe --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/RemoteLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/RemoteLocation.java new file mode 100644 index 000000000..1fbdd509e --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/URILocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/URILocation.java new file mode 100644 index 000000000..c28535772 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/location/URLLocation.java b/scijava-io3/src/main/java/org/scijava/io3/location/URLLocation.java new file mode 100644 index 000000000..5e4302614 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver b/scijava-io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver new file mode 100644 index 000000000..4da1bcf87 --- /dev/null +++ b/scijava-io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver @@ -0,0 +1 @@ +org.scijava.io3.location.FileLocationResolver diff --git a/scijava-io3/src/test/java/org/scijava/io3/location/LocationsTest.java b/scijava-io3/src/test/java/org/scijava/io3/location/LocationsTest.java new file mode 100644 index 000000000..ea7556815 --- /dev/null +++ b/scijava-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)); + } +} From c7842b0ff2e1076d2ccaa54b507d4e8b85f56380 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 14:22:47 -0500 Subject: [PATCH 12/59] scijava-io3: port data handles Ports org.scijava.io.handle as org.scijava.io3.handle, plus Bytes, which the handles need. Same standalone approach as the locations: plain ServiceLoader, no container, scijava-collections the only dependency. DataHandle no longer extends WrapperPlugin. That contract was small -- getType() plus set/get -- so it is inlined as locationType(), supports(), set(), get() and priority(). Nothing else among the fifteen handle classes needed the plugin framework. DataHandleService becomes DataHandles, which also absorbs the static helpers from SciJava Common's DataHandles utility class. Two things the port made explicit: - Discovered handles are prototypes. A handle carries the position and buffers of one open stream, so create() must instantiate a fresh one per call rather than hand out the discovered instance. SciJava Common got this from WrapperService.create(); here it is explicit, with a test asserting that two handles on one file have independent offsets. - DataHandles.copy reported progress to a Task, which also carried cancellation. Progress is now a plain LongConsumer of the running byte count, so callers wanting org.scijava.progress can adapt it in a lambda. Cancellation is therefore no longer supported by copy; if it is wanted it should be a deliberate mechanism, not a side effect of the progress type. Recorded as an open question in the plan. Still to port in this component: io.nio. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 13 + docs/migration.md | 5 +- scijava-io3/src/main/java/module-info.java | 7 + .../src/main/java/org/scijava/io3/Bytes.java | 785 ++++++++++++++++++ .../io3/handle/AbstractDataHandle.java | 94 +++ .../io3/handle/AbstractHigherOrderHandle.java | 112 +++ .../handle/AbstractSeekableStreamHandle.java | 103 +++ .../io3/handle/AbstractStreamHandle.java | 63 ++ .../org/scijava/io3/handle/BytesHandle.java | 183 ++++ .../org/scijava/io3/handle/DataHandle.java | 723 ++++++++++++++++ .../io3/handle/DataHandleInputStream.java | 119 +++ .../io3/handle/DataHandleOutputStream.java | 89 ++ .../org/scijava/io3/handle/DataHandles.java | 286 +++++++ .../org/scijava/io3/handle/DummyHandle.java | 144 ++++ .../org/scijava/io3/handle/FileHandle.java | 325 ++++++++ .../io3/handle/ReadBufferDataHandle.java | 319 +++++++ .../io3/handle/ResettableStreamHandle.java | 70 ++ .../io3/handle/SeekableStreamHandle.java | 51 ++ .../org/scijava/io3/handle/StreamHandle.java | 190 +++++ .../io3/handle/WriteBufferDataHandle.java | 203 +++++ .../scijava/io3/handle/DataHandlesTest.java | 176 ++++ 21 files changed, 4058 insertions(+), 2 deletions(-) create mode 100644 scijava-io3/src/main/java/org/scijava/io3/Bytes.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/BytesHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/DataHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/DummyHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/FileHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/StreamHandle.java create mode 100644 scijava-io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java create mode 100644 scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index a85924f88..b5210ad44 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -307,6 +307,19 @@ No application context required by anything in this phase. 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. + - **Open question:** `DataHandles.copy` took a `Task`, which carried both + progress *and cancellation*; its replacement `LongConsumer` carries only + progress. If cancellable copies are wanted, that needs a deliberate + mechanism rather than falling out of the progress type. + - Still to port: `io.nio`. - **`scijava-events`** (new, `org.scijava.events`): a clean-room event bus, no context, designed for typed topics and weak-reference subscribers. Not a port of the bushe fork — rewriting removes the attribution obligation. The diff --git a/docs/migration.md b/docs/migration.md index bee39ba44..ebaa2f196 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -23,7 +23,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | `scijava-io3`, alongside the handles that need it (Phase 1, in progress) | planned | +| `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 | @@ -99,7 +99,8 @@ A **dropped** row is a successful outcome, not a gap. | `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`, `io.nio` | `org.scijava.io3.handle` (Phase 1, in progress) | planned | +| `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` | not yet ported — `ByteBuffer`-backed byte banks | planned | | `io` (`IOPlugin`, `IOService`, `RecentFileService`) | `scijava-io3` (Phase 1), pending review of what is still used | open | | `event`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | | `parse` | Parsington (`org.scijava.parsington`) | planned | diff --git a/scijava-io3/src/main/java/module-info.java b/scijava-io3/src/main/java/module-info.java index ede880683..1fae009af 100644 --- a/scijava-io3/src/main/java/module-info.java +++ b/scijava-io3/src/main/java/module-info.java @@ -30,12 +30,19 @@ 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/scijava-io3/src/main/java/org/scijava/io3/Bytes.java b/scijava-io3/src/main/java/org/scijava/io3/Bytes.java new file mode 100644 index 000000000..9a776fde6 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java new file mode 100644 index 000000000..d6a5a7520 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java new file mode 100644 index 000000000..a1614c170 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java new file mode 100644 index 000000000..f9cc3c2dd --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java new file mode 100644 index 000000000..3f467d629 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/BytesHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/BytesHandle.java new file mode 100644 index 000000000..bc6490785 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandle.java new file mode 100644 index 000000000..f578c5e17 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java new file mode 100644 index 000000000..aaa3a6cd6 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java new file mode 100644 index 000000000..da58984ce --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java new file mode 100644 index 000000000..3a2523b92 --- /dev/null +++ b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java @@ -0,0 +1,286 @@ +/* + * #%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.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 + */ + 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) { + 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/scijava-io3/src/main/java/org/scijava/io3/handle/DummyHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/DummyHandle.java new file mode 100644 index 000000000..048d73797 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/FileHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/FileHandle.java new file mode 100644 index 000000000..5f11dcab3 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java new file mode 100644 index 000000000..cb6125b0f --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java new file mode 100644 index 000000000..580c74952 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java new file mode 100644 index 000000000..9d6f65347 --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/StreamHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/StreamHandle.java new file mode 100644 index 000000000..99b0b23ce --- /dev/null +++ b/scijava-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/scijava-io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java b/scijava-io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java new file mode 100644 index 000000000..9c6bc3bde --- /dev/null +++ b/scijava-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/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java b/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java new file mode 100644 index 000000000..c045938e3 --- /dev/null +++ b/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java @@ -0,0 +1,176 @@ +/* + * #%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.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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +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)); + } + + /** 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. + } +} From ea1ac3c57c3aa4a25c96c0cda85213e50e7a19a8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 15:00:55 -0500 Subject: [PATCH 13/59] Make cancellation thread interruption SciJava has accumulated several cancellation APIs -- Cancelable in SciJava Common and ImageJ Ops, the cancel half of task.Task, Appose's cancel() -- and SciJava Ops shipped with none of them. Rather than invent a fourth, use the platform's: interruption as the mechanism, and Future.cancel(true) as the API a caller holds. Every scheme is cooperative, so that is not what distinguishes them; composition is. An ambient thread flag is honored by code we did not write, including every interruptible JDK blocking call, while an isCanceled() interface only works for code that knows about it, and a cancellation token pollutes every signature while still being unable to abort a blocking call. Note that an interrupt carries no reason: Thread.interrupt() takes no argument and the JDK constructs InterruptedException with a null message. A reason belongs to whoever requested cancellation, not to the exception. Implemented here: - Progress.checkCancellation() throws CancellationException, restoring the interrupt flag first so code further up the stack still sees it. Progress.update() calls it, so anything already reporting progress is cancellable with no new API and no extra parameter. Ops cannot throw InterruptedException, being plain Functions, hence the unchecked CancellationException, which is also what Future.get() throws. - DataHandles.copy checks the flag once per block and throws InterruptedIOException, which is an IOException -- so the signature is unchanged -- with bytesTransferred set to the partial copy. The granularity matches the task.isCanceled() check it replaces; a read already under way cannot be aborted, since plain streams are not interruptible. Cancelable is therefore not ported at all. The docs record the design, its accepted limits (CompletableFuture.cancel does not interrupt; the flag does not inherit to worker threads; native code ignores interrupts), and why Appose's explicit cancel() stays as it is: interruption cannot cross a process boundary. Still to do: Parallelization and TaskExecutor must propagate cancellation to worker futures, since the interrupt flag does not inherit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 104 ++++++++++- docs/migration.md | 2 +- .../org/scijava/io3/handle/DataHandles.java | 18 ++ .../scijava/io3/handle/DataHandlesTest.java | 64 +++++++ .../java/org/scijava/progress/Progress.java | 35 ++++ .../scijava/progress/CancellationTest.java | 167 ++++++++++++++++++ 6 files changed, 381 insertions(+), 9 deletions(-) create mode 100644 scijava-progress/src/test/java/org/scijava/progress/CancellationTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index b5210ad44..858140a1a 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -217,6 +217,92 @@ has always had. The cost to a plugin module is one line: 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`. So `Parallelization` and + `TaskExecutor` must propagate cancellation to worker futures deliberately. + That is the one piece of real work this design implies. +- 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. +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. @@ -251,9 +337,9 @@ No application context required by anything in this phase. 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 - (cancellation concerns a running thing — it belongs with the execution - layer, near `Progress`/`Task`), as do `Validated`, `BasicDetails`, + 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; @@ -315,10 +401,11 @@ No application context required by anything in this phase. 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. - - **Open question:** `DataHandles.copy` took a `Task`, which carried both - progress *and cancellation*; its replacement `LongConsumer` carries only - progress. If cancellable copies are wanted, that needs a deliberate - mechanism rather than falling out of the progress type. + - `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`. - Still to port: `io.nio`. - **`scijava-events`** (new, `org.scijava.events`): a clean-room event bus, no context, designed for typed topics and weak-reference subscribers. Not a @@ -386,7 +473,8 @@ No application context required by anything in this phase. useful it runs the other way, `ops-engine` → execution layer. - **Execution module** (name TBD): pre/postprocessor chains, input resolution, a `@Parameter` equivalent, and a runner — built on `scijava-struct`. - `Cancelable`'s replacement lives here. + Cancellation needs nothing here: the runner hands back a `Future`, and + cancelling it interrupts the worker. - **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 diff --git a/docs/migration.md b/docs/migration.md index ebaa2f196..0c86a096c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -79,7 +79,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | execution layer (Phase 3) — cancellation concerns a running thing | 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`, `Contextual`, `AbstractContextual`, `NullContextException`, `NoSuchServiceException` | `org.scijava.context` (Phase 2) | planned | diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java index 3a2523b92..27944b822 100644 --- a/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java +++ b/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java @@ -31,6 +31,7 @@ import java.io.DataOutput; import java.io.IOException; +import java.io.InterruptedIOException; import java.io.UTFDataFormatException; import java.util.ArrayList; import java.util.Comparator; @@ -187,6 +188,9 @@ public static long copy(final DataHandle in, * @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, @@ -195,6 +199,20 @@ public static long copy(final DataHandle in, 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) { diff --git a/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java b/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java index c045938e3..d96514f04 100644 --- a/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java +++ b/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java @@ -33,17 +33,23 @@ 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; @@ -153,6 +159,64 @@ public void testCopyReportsProgress(@TempDir final Path dir) 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() { diff --git a/scijava-progress/src/main/java/org/scijava/progress/Progress.java b/scijava-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/scijava-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/test/java/org/scijava/progress/CancellationTest.java b/scijava-progress/src/test/java/org/scijava/progress/CancellationTest.java new file mode 100644 index 000000000..873a6d436 --- /dev/null +++ b/scijava-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(); + } + } +} From ef686240c90f39a3ea1c203ab19d18d3e76a2671 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 15:02:45 -0500 Subject: [PATCH 14/59] scijava-concurrent: let cancellation survive a parallel section Interrupting a thread that had farmed work out to a TaskExecutor cancelled the workers but lost the cancellation at the caller: the InterruptedException was wrapped in a plain RuntimeException, and the flag it cleared on the way out was never restored. Code further up the stack could not tell cancellation from failure, and an outer op polling Progress.checkCancellation() would never see it. Half the propagation was already correct and worth noting, since it is easy to reimplement by accident: ExecutorService.invokeAll cancels its unfinished tasks when the awaiting thread is interrupted, and cancels them with interruption. The missing half was the caller's own flag. The two call sites now separate InterruptedException from ExecutionException, restore the flag, and throw CancellationException -- the same type Progress.checkCancellation() throws, so one exception type means "cancelled" throughout. The test fails against the previous code with exactly the symptom described: "expected CancellationException, got: java.lang.RuntimeException: java.lang.InterruptedException". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 15 ++- .../org/scijava/concurrent/TaskExecutors.java | 27 ++++- .../scijava/concurrent/CancellationTest.java | 102 ++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 scijava-concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 858140a1a..10963adc6 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -272,9 +272,14 @@ reporting progress. 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`. So `Parallelization` and - `TaskExecutor` must propagate cancellation to worker futures deliberately. - That is the one piece of real work this design implies. + `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. @@ -296,7 +301,9 @@ it stands**. In-process: interruption. Across processes: an explicit protocol. 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. +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 diff --git a/scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java b/scijava-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/scijava-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/scijava-concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java b/scijava-concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java new file mode 100644 index 000000000..5073dd127 --- /dev/null +++ b/scijava-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"); + } +} From df12101c1d681b78fa53e70a74e91272d19861f7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 15:03:28 -0500 Subject: [PATCH 15/59] docs: do not port io.nio NIOService and DefaultNIOService wrap two JDK calls behind a system-property switch, and have no usages in the scijava or imagej repositories. ByteBufferByteBank has none either, and caps at Integer.MAX_VALUE exactly as ByteArrayByteBank does, so it adds no capability -- only a pluggable allocator such as ByteBuffer::allocateDirect. Since ByteBank itself is ported, a direct-buffer bank is a small class to write when something actually needs one. NB: SCIFIO is the most plausible consumer and could not be checked, there being no local checkout. This completes scijava-io3. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 6 +++++- docs/migration.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 10963adc6..def65af7f 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -413,7 +413,11 @@ No application context required by anything in this phase. Cancellation is thread interruption instead — see the cancellation section — so `copy` checks the flag once per block and throws `InterruptedIOException`. - - Still to port: `io.nio`. + - `io.nio` is **not ported**: `NIOService` wraps two JDK calls and has no + usages, and `ByteBufferByteBank` has none either and caps at + `Integer.MAX_VALUE` just as `ByteArrayByteBank` does, so it adds no + capability. See [migration.md](migration.md). This component is therefore + complete. - **`scijava-events`** (new, `org.scijava.events`): a clean-room event bus, no context, designed for typed topics and weak-reference subscribers. Not a port of the bushe fork — rewriting removes the attribution obligation. The diff --git a/docs/migration.md b/docs/migration.md index 0c86a096c..37706b41f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -100,7 +100,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | not yet ported — `ByteBuffer`-backed byte banks | planned | +| `io.nio` | **not ported.** `NIOService`/`DefaultNIOService` wrap two JDK calls (`ByteBuffer.allocate` + `FileChannel.read`, or `FileChannel.map`) behind a system-property switch, and have no usages in the scijava or imagej repositories. `ByteBufferByteBank` also has none, and caps at `Integer.MAX_VALUE` like `ByteArrayByteBank`, so it adds no capability — only a pluggable allocator, e.g. `ByteBuffer::allocateDirect`. `ByteBank` is ported, so a direct-buffer bank is a small class to add when something needs one. NB: SCIFIO was not checked, having no local checkout | dropped | | `io` (`IOPlugin`, `IOService`, `RecentFileService`) | `scijava-io3` (Phase 1), pending review of what is still used | open | | `event`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | | `parse` | Parsington (`org.scijava.parsington`) | planned | From 80f4333dd949ab35581c64d0aeef289f11341e79 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 15:40:34 -0500 Subject: [PATCH 16/59] scijava-io3: port ByteBufferByteBank, keeping direct banks off-heap I had dropped this as adding no capability over ByteArrayByteBank, since both cap at Integer.MAX_VALUE. That was wrong: a direct ByteBuffer is off-heap, so its bytes can be handed to native code and to shared memory without a copy. That matters increasingly for Fiji as Appose's named shared memory blocks become more prominent. Ported with two additions that make the capability usable and observable: direct() factories, and an isDirect() accessor. It also fixes a bug that defeated the entire purpose. Growing the bank reallocated with ByteBuffer.allocate, so a direct bank quietly became a heap bank as soon as it outgrew its initial capacity -- and since the default capacity is 10,000 bytes, any real payload would trip it. Growth now goes through the bank's own provider, and a test asserts a direct bank is still direct after growing well past its initial capacity. NIOService stays dropped: 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 without using it. Nothing in the ported architecture precludes FileChannel-based access later -- a handle may open a channel, and a byte bank may be backed by a mapped buffer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 11 +- docs/migration.md | 3 +- .../org/scijava/io3/ByteBufferByteBank.java | 216 ++++++++++++++++++ .../scijava/io3/ByteBufferByteBankTest.java | 102 +++++++++ 4 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 scijava-io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java create mode 100644 scijava-io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index def65af7f..c11f09f86 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -413,10 +413,13 @@ No application context required by anything in this phase. Cancellation is thread interruption instead — see the cancellation section — so `copy` checks the flag once per block and throws `InterruptedIOException`. - - `io.nio` is **not ported**: `NIOService` wraps two JDK calls and has no - usages, and `ByteBufferByteBank` has none either and caps at - `Integer.MAX_VALUE` just as `ByteArrayByteBank` does, so it adds no - capability. See [migration.md](migration.md). This component is therefore + - 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 event bus, no context, designed for typed topics and weak-reference subscribers. Not a diff --git a/docs/migration.md b/docs/migration.md index 37706b41f..913437e93 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -100,7 +100,8 @@ A **dropped** row is a successful outcome, not a gap. | `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` | **not ported.** `NIOService`/`DefaultNIOService` wrap two JDK calls (`ByteBuffer.allocate` + `FileChannel.read`, or `FileChannel.map`) behind a system-property switch, and have no usages in the scijava or imagej repositories. `ByteBufferByteBank` also has none, and caps at `Integer.MAX_VALUE` like `ByteArrayByteBank`, so it adds no capability — only a pluggable allocator, e.g. `ByteBuffer::allocateDirect`. `ByteBank` is ported, so a direct-buffer bank is a small class to add when something needs one. NB: SCIFIO was not checked, having no local checkout | dropped | +| `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`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | | `parse` | Parsington (`org.scijava.parsington`) | planned | diff --git a/scijava-io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java b/scijava-io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java new file mode 100644 index 000000000..b8c96a1a0 --- /dev/null +++ b/scijava-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/scijava-io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java b/scijava-io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java new file mode 100644 index 000000000..38611932f --- /dev/null +++ b/scijava-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()); + } +} From 503983771a2a19033616fb3c0ccc974e3542f907 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 16:05:56 -0500 Subject: [PATCH 17/59] scijava-events: a typed event bus, written clean Replaces SciJava Common's event package without porting it, so the bushe fork and its attribution obligation are gone. subscribe(Class, Consumer) is type-safe, needs no reflection and therefore no `opens`, and returns a Subscription. The @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, and a service author still never writes cleanup code. Subscribers are held strongly. SciJava Common held them weakly, which produced handlers that silently stopped firing, and forced a WeakHashMap named keepEm whose only job was to stop the proxies being collected too early. Cleanup is structural instead: EventBus.close() drops every subscription, and a context calls it on dispose. 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. NB: this is the opposite call from Locations.get() and DataHandles.get(), which are safe to share precisely because they hold no such state. Also: - Class-hierarchy dispatch is kept, since it is what makes a bus more than a listener list, and any object may be an event -- no SciJavaEvent base type, so a class from another library can be one. - Consumption is opt-in through a Consumable interface on the event, modeled on AWT's InputEvent.consume(), so toolkit-style input events can 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 the first 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 and the rest suppressed. Cancellation is the exception -- an interrupted subscriber, or one throwing CancellationException, stops delivery at once rather than being buried among unrelated failures. EventHistory is dropped; a subscriber on Object.class reproduces it. scijava-listeners is not folded in after all: a listener list is the right primitive for "this object notifies its observers", and is a different thing from a bus. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 48 ++- docs/migration.md | 3 +- pom.xml | 1 + scijava-events/pom.xml | 137 ++++++++ scijava-events/src/main/java/module-info.java | 36 ++ .../java/org/scijava/events/Consumable.java | 59 ++++ .../org/scijava/events/DefaultEventBus.java | 165 ++++++++++ .../java/org/scijava/events/EventBus.java | 127 +++++++ .../events/EventDeliveryException.java | 67 ++++ .../java/org/scijava/events/Subscription.java | 57 ++++ .../java/org/scijava/events/EventBusTest.java | 310 ++++++++++++++++++ 11 files changed, 1005 insertions(+), 5 deletions(-) create mode 100644 scijava-events/pom.xml create mode 100644 scijava-events/src/main/java/module-info.java create mode 100644 scijava-events/src/main/java/org/scijava/events/Consumable.java create mode 100644 scijava-events/src/main/java/org/scijava/events/DefaultEventBus.java create mode 100644 scijava-events/src/main/java/org/scijava/events/EventBus.java create mode 100644 scijava-events/src/main/java/org/scijava/events/EventDeliveryException.java create mode 100644 scijava-events/src/main/java/org/scijava/events/Subscription.java create mode 100644 scijava-events/src/test/java/org/scijava/events/EventBusTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index c11f09f86..dc0ddeff4 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -421,10 +421,50 @@ No application context required by anything in this phase. 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 event bus, - no context, designed for typed topics and weak-reference subscribers. Not a - port of the bushe fork — rewriting removes the attribution obligation. The - external `scijava-listeners` folds in here. +- **`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 diff --git a/docs/migration.md b/docs/migration.md index 913437e93..48644d042 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -103,7 +103,8 @@ A **dropped** row is a successful outcome, not a gap. | `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`, `event.bushe` | `org.scijava.events` — clean-room rewrite, not a port (Phase 1) | planned | +| `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 | diff --git a/pom.xml b/pom.xml index 90f2f3dd6..29f5539d7 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ scijava-concurrent scijava-discovery scijava-discovery-test + scijava-events scijava-function scijava-index scijava-io3 diff --git a/scijava-events/pom.xml b/scijava-events/pom.xml new file mode 100644 index 000000000..f27e59fe5 --- /dev/null +++ b/scijava-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/scijava-events/src/main/java/module-info.java b/scijava-events/src/main/java/module-info.java new file mode 100644 index 000000000..91a824c96 --- /dev/null +++ b/scijava-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/scijava-events/src/main/java/org/scijava/events/Consumable.java b/scijava-events/src/main/java/org/scijava/events/Consumable.java new file mode 100644 index 000000000..bbfb62004 --- /dev/null +++ b/scijava-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/scijava-events/src/main/java/org/scijava/events/DefaultEventBus.java b/scijava-events/src/main/java/org/scijava/events/DefaultEventBus.java new file mode 100644 index 000000000..3fe655034 --- /dev/null +++ b/scijava-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/scijava-events/src/main/java/org/scijava/events/EventBus.java b/scijava-events/src/main/java/org/scijava/events/EventBus.java new file mode 100644 index 000000000..ebf3baf34 --- /dev/null +++ b/scijava-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/scijava-events/src/main/java/org/scijava/events/EventDeliveryException.java b/scijava-events/src/main/java/org/scijava/events/EventDeliveryException.java new file mode 100644 index 000000000..1ae512b24 --- /dev/null +++ b/scijava-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/scijava-events/src/main/java/org/scijava/events/Subscription.java b/scijava-events/src/main/java/org/scijava/events/Subscription.java new file mode 100644 index 000000000..ebb3dca8d --- /dev/null +++ b/scijava-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/scijava-events/src/test/java/org/scijava/events/EventBusTest.java b/scijava-events/src/test/java/org/scijava/events/EventBusTest.java new file mode 100644 index 000000000..75cca5cfc --- /dev/null +++ b/scijava-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); + } +} From e09d2808c52c51c830761454b1b9d12553d3c0b9 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 19:46:24 -0500 Subject: [PATCH 18/59] docs: record incarnational versioning, and defer scijava-persist The digit in common3 and io3 is the component's incarnation, not a workaround for a package collision: breaking changes fork to a new incarnation with a new artifactId and package prefix, so both can sit on one classpath and downstream code keeps working. The rule that makes deprecate-don't-break coherent: a major version bump within one incarnation should never happen. If compatibility must break, the incarnation digit moves with it. Ideally the major version equals the incarnation number, which today it does not, because the first release of this reactor was a single monoversioned big bang -- releasing a reactor piecemeal with heterogeneous versions needs tooling that release-version.sh does not yet have. Removing the interfaces that moved to scijava-spi is the realignment opportunity, since it breaks compatibility anyway. scijava-persist is recorded as later-phase: the idea is sound and the discovery half is SciJava-shaped, but the code hard-wires Gson into its SPI, vendors Gson's RuntimeTypeAdapterFactory, and is built on Context. A rewrite would be smaller than a port, and there is no concrete core consumer today. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index dc0ddeff4..de0f5265d 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -42,9 +42,26 @@ The deliverable is not a reimplementation of SJC. It is: 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. -- **Names are case by case.** Where an SJ3 package would collide with an SJC - one, pick a natural collision-free noun if one exists; otherwise use a `3` - suffix (`common3`, `io3`). Every decision is recorded in +- **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 the + [incubator README](https://github.com/scijava/incubator/blob/-/README.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`. +- **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 @@ -613,6 +630,19 @@ subsystems, with no dependency on external `scijava-` components. intended to work standalone — Parsington, for instance — keep their independence and should not carry the `scijava-` prefix. +## Later, not now + +- **`scijava-persist`** (in the incubator): 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. + ## Open decisions - **Names:** the execution-layer module, and `scijava-convert3`. From f65c690009faf3ffe5d41c086500a79d4b6a4be7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 19:51:45 -0500 Subject: [PATCH 19/59] scijava-io3: start at 3.0.0-SNAPSHOT The major version tracks the incarnation, per the incubator README: an artifact named io3 is the third incarnation and should be 3.x.y. The 1.x.y of the other components is a legacy of the first monoversioned release rather than the intended scheme, and this one is unreleased, so it can simply start out aligned. Releasing a reactor with heterogeneous versions still awaits the release-version.sh improvements; nothing here depends on that landing first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 3 +++ scijava-io3/pom.xml | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index de0f5265d..2f2112b6b 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -59,6 +59,9 @@ The deliverable is not a reimplementation of SJC. It is: 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 diff --git a/scijava-io3/pom.xml b/scijava-io3/pom.xml index 9bcdffad6..3dc6bed1c 100644 --- a/scijava-io3/pom.xml +++ b/scijava-io3/pom.xml @@ -10,7 +10,12 @@ scijava-io3 - 1.0.0-SNAPSHOT + + 3.0.0-SNAPSHOT SciJava I/O Locations and data handles: a uniform way to address and read bytes. From caa964001fa11bfe5a20716943f812e4b27b37ec Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 20:01:01 -0500 Subject: [PATCH 20/59] scijava-context: the container core Phase 2 begins: Service, Context, lazy creation, priority selection, lifecycle, disposal, and one EventBus per context. Services are discovered as Service, never as their own interface, and this is forced rather than chosen: ServiceLoader resolves `uses` against its caller's module, so a container cannot look up an arbitrary third-party interface on that module's behalf -- it would have to declare `uses com.example.LogService`, which it cannot know. Declaring one base type that this module does `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. Services are created lazily, where SciJava Common built every service it could find at context creation. Asking for one constructs it, initializes it, and constructs whatever it asks for in turn. scijava-context-test is a sibling module because that is the only honest way to test what matters: a module must really declare `provides`, and its implementation package must really be unexported, or the test proves nothing. It verifies that a service in a package that is neither exported nor opened is still discovered and constructed, and asserts that it is running on the module path rather than skipping quietly should the build ever stop being modular. Two bugs the tests caught, both ordering: - Context.of initialized each service as it registered, so a service depending on one declared after it failed. Registration and initialization are now separate phases. - Services were recorded for disposal on the way in, before their dependencies had resolved, so reversing that list tore dependencies down before their dependents -- exactly backwards. They are now recorded once initialization finishes, so dependencies land earlier and reversing is correct. Note a consequence of acquiring dependencies in initialize rather than in the constructor: two services may depend on each other and it resolves, where constructor injection could not do it at all. The cost, documented on the method, is that a service in such a cycle may briefly observe a peer that has not finished initializing. Contextual, AbstractContextual and NullContextException are not ported: a service receives its context in initialize, so it need not carry one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 24 ++ docs/migration.md | 5 +- pom.xml | 2 + scijava-context-test/pom.xml | 141 ++++++++ .../src/main/java/module-info.java | 40 +++ .../scijava/context/test/GreeterService.java | 38 +++ .../context/test/impl/HiddenGreeter.java | 49 +++ .../context/test/ServiceDiscoveryTest.java | 103 ++++++ scijava-context/pom.xml | 149 ++++++++ .../src/main/java/module-info.java | 40 +++ .../java/org/scijava/context/Context.java | 291 ++++++++++++++++ .../context/NoSuchServiceException.java | 50 +++ .../java/org/scijava/context/Service.java | 107 ++++++ .../org/scijava/context/ServiceException.java | 44 +++ .../java/org/scijava/context/ContextTest.java | 321 ++++++++++++++++++ 15 files changed, 1402 insertions(+), 2 deletions(-) create mode 100644 scijava-context-test/pom.xml create mode 100644 scijava-context-test/src/main/java/module-info.java create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/GreeterService.java create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java create mode 100644 scijava-context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java create mode 100644 scijava-context/pom.xml create mode 100644 scijava-context/src/main/java/module-info.java create mode 100644 scijava-context/src/main/java/org/scijava/context/Context.java create mode 100644 scijava-context/src/main/java/org/scijava/context/NoSuchServiceException.java create mode 100644 scijava-context/src/main/java/org/scijava/context/Service.java create mode 100644 scijava-context/src/main/java/org/scijava/context/ServiceException.java create mode 100644 scijava-context/src/test/java/org/scijava/context/ContextTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 2f2112b6b..2b34468d9 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -504,6 +504,30 @@ No application context required by anything in this phase. 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. + - Still to do here: the `@Plugin` tier via `scijava-index`, declarative + field injection through a qualified `opens`, `@EventHandler` scanning, the + one-way legacy bridge, and the core services themselves. - **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 diff --git a/docs/migration.md b/docs/migration.md index 48644d042..56580982f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -82,7 +82,8 @@ A **dropped** row is a successful outcome, not a gap. | `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`, `Contextual`, `AbstractContextual`, `NullContextException`, `NoSuchServiceException` | `org.scijava.context` (Phase 2) | planned | +| `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` | plugin/module metadata (Phases 2–4) | open | @@ -111,7 +112,7 @@ A **dropped** row is a successful outcome, not a gap. | `thread` | `org.scijava.concurrent` | ported | | `task` | `org.scijava.progress` | ported | | `plugin` | `org.scijava.context` + `org.scijava.index` + `Discoverer` (Phase 2) | planned | -| `service` | `org.scijava.context` — discovered via `ServiceLoader`; `Service` no longer extends `SciJavaPlugin` (Phase 2) | planned | +| `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`, `prefs`, `app` | `org.scijava.context` core services (Phase 2) | planned | | `convert` | `scijava-convert3` (Phase 2) | planned | | `module`, `module.process`, `command` | execution layer on `scijava-struct` (Phase 3) | planned | diff --git a/pom.xml b/pom.xml index 29f5539d7..51729bdb4 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,8 @@ scijava-collections scijava-common3 + scijava-context + scijava-context-test scijava-concurrent scijava-discovery scijava-discovery-test diff --git a/scijava-context-test/pom.xml b/scijava-context-test/pom.xml new file mode 100644 index 000000000..89916ceef --- /dev/null +++ b/scijava-context-test/pom.xml @@ -0,0 +1,141 @@ + + + 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 + + + + + org.scijava + scijava-context + ${scijava-context.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/scijava-context-test/src/main/java/module-info.java b/scijava-context-test/src/main/java/module-info.java new file mode 100644 index 000000000..b743ea322 --- /dev/null +++ b/scijava-context-test/src/main/java/module-info.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% + */ + +module org.scijava.context.test { + + // NB: the implementation package is deliberately NOT exported. + exports org.scijava.context.test; + + requires org.scijava.context; + + provides org.scijava.context.Service with + org.scijava.context.test.impl.HiddenGreeter; + +} diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/GreeterService.java b/scijava-context-test/src/main/java/org/scijava/context/test/GreeterService.java new file mode 100644 index 000000000..644e04874 --- /dev/null +++ b/scijava-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/scijava-context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java b/scijava-context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java new file mode 100644 index 000000000..58e6e79b5 --- /dev/null +++ b/scijava-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/scijava-context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java b/scijava-context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java new file mode 100644 index 000000000..670c3ec64 --- /dev/null +++ b/scijava-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/scijava-context/pom.xml b/scijava-context/pom.xml new file mode 100644 index 000000000..c3093cfc7 --- /dev/null +++ b/scijava-context/pom.xml @@ -0,0 +1,149 @@ + + + 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.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + + + + + 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/scijava-context/src/main/java/module-info.java b/scijava-context/src/main/java/module-info.java new file mode 100644 index 000000000..dfd8a1b1f --- /dev/null +++ b/scijava-context/src/main/java/module-info.java @@ -0,0 +1,40 @@ +/* + * #%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.events; + requires transitive org.scijava.spi; + requires org.scijava.priority; + + uses org.scijava.context.Service; + +} diff --git a/scijava-context/src/main/java/org/scijava/context/Context.java b/scijava-context/src/main/java/org/scijava/context/Context.java new file mode 100644 index 000000000..98598c588 --- /dev/null +++ b/scijava-context/src/main/java/org/scijava/context/Context.java @@ -0,0 +1,291 @@ +/* + * #%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.util.ArrayList; +import java.util.Collections; +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.events.EventBus; +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(); + + private 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); + 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; + } + } + + /** 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 synchronized void dispose() { + if (disposed) return; + disposed = true; + final List failures = new ArrayList<>(); + for (int i = created.size() - 1; i >= 0; i--) { + try { + created.get(i).dispose(); + } + catch (final Throwable exc) { + failures.add(exc); + } + } + created.clear(); + byRequestedType.clear(); + // 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 -- + + /** + * 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/scijava-context/src/main/java/org/scijava/context/NoSuchServiceException.java b/scijava-context/src/main/java/org/scijava/context/NoSuchServiceException.java new file mode 100644 index 000000000..a010e8c77 --- /dev/null +++ b/scijava-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/scijava-context/src/main/java/org/scijava/context/Service.java b/scijava-context/src/main/java/org/scijava/context/Service.java new file mode 100644 index 000000000..4ff05ac1f --- /dev/null +++ b/scijava-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/scijava-context/src/main/java/org/scijava/context/ServiceException.java b/scijava-context/src/main/java/org/scijava/context/ServiceException.java new file mode 100644 index 000000000..7f02fb53e --- /dev/null +++ b/scijava-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/scijava-context/src/test/java/org/scijava/context/ContextTest.java b/scijava-context/src/test/java/org/scijava/context/ContextTest.java new file mode 100644 index 000000000..7db1a8795 --- /dev/null +++ b/scijava-context/src/test/java/org/scijava/context/ContextTest.java @@ -0,0 +1,321 @@ +/* + * #%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.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.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); + } + + // -- 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)); + } + } +} From 7822074c92376f6635966a1485090af6f93f7508 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 20:09:48 -0500 Subject: [PATCH 21/59] scijava-context: the plugin tier @Plugin (indexable, with @Attr) and Context.plugins(Class), which returns Discovery descriptors straight from the annotation index: class name, metadata and priority readable with nothing loaded, and construction only on demand. That is what keeps a menu of a thousand commands cheap to build, and it is the payoff for the Discovery refactor. scijava-context-test exercises the whole path through a real build: annotated classes in a package that is opened to the container but exported to nobody, indexed by the annotation processor at compile time, discovered with their metadata, constructed only when asked. Two JPMS findings, neither of which survives reasoning alone: - An `opens` must name the module that actually performs the reflection. The first attempt failed with IllegalAccessException because the package was opened to org.scijava.context while newInstance was being called inside org.scijava.discovery. Generic discovery code therefore cannot construct a plugin on a container's behalf: Discovery.of and IndexDiscoverer now take an optional instantiator, and the context supplies one defined in its own module, so users open their packages to org.scijava.context exactly as @Plugin's javadoc says. - The annotation processor must be on the annotation processor path, and its absence is silent. The first build produced no index and reported nothing: javac does not find processors on the module path, and says nothing when it finds none at all. This is the hazard recorded in the plan, met first-hand. Whether pom-scijava should configure this for SciJava components is worth deciding, since the people most likely to fall into it are the hobbyists @Plugin exists to serve. SciJava Common's HandlerPlugin/WrapperPlugin/TypedPlugin/SingletonPlugin hierarchy is not ported: each user inlines the small contract it needs, as DataHandle already did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 28 +++- docs/migration.md | 3 +- scijava-context-test/pom.xml | 32 +++++ .../src/main/java/module-info.java | 6 +- .../org/scijava/context/test/Shouter.java | 36 +++++ .../context/test/impl/LoudShouter.java | 53 +++++++ .../context/test/impl/QuietShouter.java | 44 ++++++ .../context/test/PluginDiscoveryTest.java | 129 ++++++++++++++++++ scijava-context/pom.xml | 12 ++ .../src/main/java/module-info.java | 2 + .../main/java/org/scijava/context/Attr.java | 53 +++++++ .../java/org/scijava/context/Context.java | 86 ++++++++++++ .../main/java/org/scijava/context/Plugin.java | 108 +++++++++++++++ .../java/org/scijava/discovery/Discovery.java | 31 +++++ .../org/scijava/index/IndexDiscoverer.java | 39 +++++- 15 files changed, 655 insertions(+), 7 deletions(-) create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/Shouter.java create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java create mode 100644 scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java create mode 100644 scijava-context/src/main/java/org/scijava/context/Attr.java create mode 100644 scijava-context/src/main/java/org/scijava/context/Plugin.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 2b34468d9..2648141b4 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -222,6 +222,7 @@ These were measured, not reasoned about, with a three-module test project | `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 @@ -230,6 +231,13 @@ 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: @@ -525,9 +533,23 @@ No application context required by anything in this phase. - **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. - - Still to do here: the `@Plugin` tier via `scijava-index`, declarative - field injection through a qualified `opens`, `@EventHandler` scanning, the - one-way legacy bridge, and the core services themselves. + - **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. + - Still to do here: declarative field injection through a qualified `opens`, + `@EventHandler` scanning, the one-way legacy bridge, and the core services + themselves. - **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 diff --git a/docs/migration.md b/docs/migration.md index 56580982f..dda537577 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -111,7 +111,8 @@ A **dropped** row is a successful outcome, not a gap. | `cache` | `scijava-cache` moves in, keeping `org.scijava.cache` (Phase 2) | planned | | `thread` | `org.scijava.concurrent` | ported | | `task` | `org.scijava.progress` | ported | -| `plugin` | `org.scijava.context` + `org.scijava.index` + `Discoverer` (Phase 2) | planned | +| `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`, `prefs`, `app` | `org.scijava.context` core services (Phase 2) | planned | | `convert` | `scijava-convert3` (Phase 2) | planned | diff --git a/scijava-context-test/pom.xml b/scijava-context-test/pom.xml index 89916ceef..c8e621ce3 100644 --- a/scijava-context-test/pom.xml +++ b/scijava-context-test/pom.xml @@ -90,6 +90,9 @@ 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT @@ -98,6 +101,16 @@ scijava-context ${scijava-context.version} + + org.scijava + scijava-discovery + ${scijava-discovery.version} + + + org.scijava + scijava-priority + ${scijava-priority.version} + @@ -110,6 +123,25 @@ + + + maven-compiler-plugin + + + + org.scijava + scijava-index + ${scijava-index.version} + + + + maven-enforcer-plugin diff --git a/scijava-context-test/src/main/java/module-info.java b/scijava-context-test/src/main/java/module-info.java index b743ea322..169716772 100644 --- a/scijava-context-test/src/main/java/module-info.java +++ b/scijava-context-test/src/main/java/module-info.java @@ -29,10 +29,14 @@ module org.scijava.context.test { - // NB: the implementation package is deliberately NOT exported. + // 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; diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/Shouter.java b/scijava-context-test/src/main/java/org/scijava/context/test/Shouter.java new file mode 100644 index 000000000..55205b820 --- /dev/null +++ b/scijava-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/scijava-context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java b/scijava-context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java new file mode 100644 index 000000000..15b9d4a2a --- /dev/null +++ b/scijava-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/scijava-context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java b/scijava-context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java new file mode 100644 index 000000000..b16ba7f3b --- /dev/null +++ b/scijava-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/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java b/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java new file mode 100644 index 000000000..f53b4a563 --- /dev/null +++ b/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java @@ -0,0 +1,129 @@ +/*- + * #%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 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. + assertEquals(List.of(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()) { + final Discovery loud = context.plugins(Shouter.class).get(0); + + 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/scijava-context/pom.xml b/scijava-context/pom.xml index c3093cfc7..f5c717262 100644 --- a/scijava-context/pom.xml +++ b/scijava-context/pom.xml @@ -86,12 +86,24 @@ 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 diff --git a/scijava-context/src/main/java/module-info.java b/scijava-context/src/main/java/module-info.java index dfd8a1b1f..a08794e35 100644 --- a/scijava-context/src/main/java/module-info.java +++ b/scijava-context/src/main/java/module-info.java @@ -31,7 +31,9 @@ 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; diff --git a/scijava-context/src/main/java/org/scijava/context/Attr.java b/scijava-context/src/main/java/org/scijava/context/Attr.java new file mode 100644 index 000000000..0a8ef266d --- /dev/null +++ b/scijava-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/scijava-context/src/main/java/org/scijava/context/Context.java b/scijava-context/src/main/java/org/scijava/context/Context.java index 98598c588..8bb6fff23 100644 --- a/scijava-context/src/main/java/org/scijava/context/Context.java +++ b/scijava-context/src/main/java/org/scijava/context/Context.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; @@ -41,7 +42,9 @@ import java.util.Set; import java.util.stream.Collectors; +import org.scijava.discovery.Discovery; import org.scijava.events.EventBus; +import org.scijava.index.IndexDiscoverer; import org.scijava.spi.Disposable; /** @@ -79,6 +82,9 @@ public class Context implements Disposable, AutoCloseable { private final EventBus events = EventBus.create(); + /** Plugin discovery, from the annotation index. Built on first use. */ + private IndexDiscoverer pluginDiscoverer; + private boolean disposed; private Context(final List> providers) { @@ -160,6 +166,35 @@ public Optional optionalService(final Class type) { } } + /** + * 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; + } + /** Gets this context's event bus. */ public EventBus events() { return events; @@ -222,6 +257,57 @@ public void close() { // -- Helper methods -- + /** + * 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 static IndexDiscoverer createPluginDiscoverer() { + return new IndexDiscoverer<>(Plugin.class, // + item -> item.annotation().type().getName(), // + item -> attrsOf(item.annotation()), // + item -> item.annotation().priority(), // + Thread.currentThread().getContextClassLoader(), // + Context::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 static Object instantiate(final Class type) { + try { + return type.getDeclaredConstructor().newInstance(); + } + catch (final ReflectiveOperationException exc) { + throw new ServiceException("Cannot construct plugin: " + // + type.getName() + ". Does its module open the package to" + // + " org.scijava.context?", exc); + } + } + + /** 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. *

diff --git a/scijava-context/src/main/java/org/scijava/context/Plugin.java b/scijava-context/src/main/java/org/scijava/context/Plugin.java new file mode 100644 index 000000000..9c7a4a637 --- /dev/null +++ b/scijava-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/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java b/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java index 22bdc991f..00c679fc5 100644 --- a/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java +++ b/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java @@ -32,6 +32,7 @@ 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; @@ -209,6 +210,35 @@ public String toString() { 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<>() { @@ -236,6 +266,7 @@ public synchronized Class type() { @Override public T get() { + if (instantiator != null) return instantiator.apply(type()); try { return type().getDeclaredConstructor().newInstance(); } diff --git a/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java b/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java index 6542431b5..a7aa3f528 100644 --- a/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java +++ b/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java @@ -69,6 +69,7 @@ public class IndexDiscoverer implements Discoverer { 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. @@ -80,7 +81,7 @@ public IndexDiscoverer(final Class annotation, final Function, String> typeNameOf) { this(annotation, typeNameOf, item -> Collections.emptyMap(), - item -> Priority.NORMAL, null); + item -> Priority.NORMAL, null, null); } /** @@ -98,12 +99,42 @@ public IndexDiscoverer(final Class annotation, 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 @@ -121,8 +152,12 @@ public List> discover(final Class c) { 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))); + attrsOf.apply(item), priorityOf.applyAsDouble(item), construct)); } return discoveries; } From 394b9561d91a30bf029a5048417d38e6b3419853 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 20:15:32 -0500 Subject: [PATCH 22/59] scijava-context: declarative dependency injection @Dependency fills in services, the Context and the EventBus, after construction and before Service.initialize, so a dependency is ready by the time initialization runs. An optional dependency (required = false) stays null; a missing required one names the field and the type it wanted. It is deliberately not called @Parameter as in SciJava Common. There that annotation meant two unrelated things -- a dependency to inject, and an input to a module -- and the second is now org.scijava.struct's business. Keeping the name would have preserved muscle memory at the price of re-importing the confusion. scijava-context-test injects private fields of a plugin across a real module boundary, which proves both halves of the JPMS claim at once: a qualified `opens` grants the container the deep reflection it needs, and the package remains exported to nobody, so ordinary callers still cannot reach the implementation. Also makes two assertions in PluginDiscoveryTest select plugins by name rather than by position. Adding a third plugin to the test module broke them, which is a fair warning about how they were written. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 15 ++- docs/migration.md | 1 + .../context/test/impl/InjectedShouter.java | 66 +++++++++++++ .../scijava/context/test/InjectionTest.java | 93 +++++++++++++++++++ .../context/test/PluginDiscoveryTest.java | 12 ++- .../java/org/scijava/context/Context.java | 78 ++++++++++++++-- .../java/org/scijava/context/Dependency.java | 86 +++++++++++++++++ .../java/org/scijava/context/ContextTest.java | 84 +++++++++++++++++ 8 files changed, 423 insertions(+), 12 deletions(-) create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java create mode 100644 scijava-context-test/src/test/java/org/scijava/context/test/InjectionTest.java create mode 100644 scijava-context/src/main/java/org/scijava/context/Dependency.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 2648141b4..63fbeefe5 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -547,9 +547,18 @@ No application context required by anything in this phase. 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. - - Still to do here: declarative field injection through a qualified `opens`, - `@EventHandler` scanning, the one-way legacy bridge, and the core services - themselves. + - **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. + - Still to do here: `@EventHandler` scanning, the one-way legacy bridge, and + the core services themselves. - **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 diff --git a/docs/migration.md b/docs/migration.md index dda537577..39e1cecdf 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -111,6 +111,7 @@ A **dropped** row is a successful outcome, not a gap. | `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 | diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java b/scijava-context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java new file mode 100644 index 000000000..61f4cfc44 --- /dev/null +++ b/scijava-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/scijava-context-test/src/test/java/org/scijava/context/test/InjectionTest.java b/scijava-context-test/src/test/java/org/scijava/context/test/InjectionTest.java new file mode 100644 index 000000000..9380d0195 --- /dev/null +++ b/scijava-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/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java b/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java index f53b4a563..fa6c9ab70 100644 --- a/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java +++ b/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java @@ -53,6 +53,8 @@ */ 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"; @@ -64,8 +66,8 @@ public void testPluginsAreDiscovered() { final List names = context.plugins(Shouter.class).stream() // .map(Discovery::implClassName) // .collect(Collectors.toList()); - // NB: highest priority first. - assertEquals(List.of(LOUD, QUIET), names); + // NB: highest priority first: VERY_HIGH, HIGH, LOW. + assertEquals(List.of(INJECTED, LOUD, QUIET), names); } } @@ -76,7 +78,11 @@ public void testPluginsAreDiscovered() { @Test public void testMetadataWithoutLoadingTheClass() { try (final Context context = Context.create()) { - final Discovery loud = context.plugins(Shouter.class).get(0); + // 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)); diff --git a/scijava-context/src/main/java/org/scijava/context/Context.java b/scijava-context/src/main/java/org/scijava/context/Context.java index 8bb6fff23..928e1cf1a 100644 --- a/scijava-context/src/main/java/org/scijava/context/Context.java +++ b/scijava-context/src/main/java/org/scijava/context/Context.java @@ -29,6 +29,7 @@ package org.scijava.context; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -154,6 +155,7 @@ public Optional optionalService(final Class type) { // NB: publish before initializing, so that two services depending // on each other resolve instead of recursing. byRequestedType.put(type, s); + inject(s); initialize(s); // NB: record for disposal only once initialization has finished. // A service's dependencies finish first, so they land earlier in @@ -195,6 +197,28 @@ public

List> plugins(final Class

type) { 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); + } + } + } + /** Gets this context's event bus. */ public EventBus events() { return events; @@ -257,6 +281,45 @@ public void close() { // -- 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); + } + } + + /** 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. *

@@ -265,13 +328,13 @@ public void close() { * annotation loads the declared type, never the implementation. *

*/ - private static IndexDiscoverer createPluginDiscoverer() { + private IndexDiscoverer createPluginDiscoverer() { return new IndexDiscoverer<>(Plugin.class, // item -> item.annotation().type().getName(), // item -> attrsOf(item.annotation()), // item -> item.annotation().priority(), // Thread.currentThread().getContextClassLoader(), // - Context::instantiate); + this::instantiate); } /** @@ -284,15 +347,18 @@ private static IndexDiscoverer createPluginDiscoverer() { * packages to {@code org.scijava.context}. *

*/ - private static Object instantiate(final Class type) { + private Object instantiate(final Class type) { + final Object plugin; try { - return type.getDeclaredConstructor().newInstance(); + plugin = type.getDeclaredConstructor().newInstance(); } catch (final ReflectiveOperationException exc) { throw new ServiceException("Cannot construct plugin: " + // - type.getName() + ". Does its module open the package to" + // - " org.scijava.context?", exc); + 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. */ diff --git a/scijava-context/src/main/java/org/scijava/context/Dependency.java b/scijava-context/src/main/java/org/scijava/context/Dependency.java new file mode 100644 index 000000000..5d0675aaa --- /dev/null +++ b/scijava-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/scijava-context/src/test/java/org/scijava/context/ContextTest.java b/scijava-context/src/test/java/org/scijava/context/ContextTest.java index 7db1a8795..a57fcb60e 100644 --- a/scijava-context/src/test/java/org/scijava/context/ContextTest.java +++ b/scijava-context/src/test/java/org/scijava/context/ContextTest.java @@ -33,6 +33,7 @@ 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; @@ -186,6 +187,89 @@ public void testDisposeClosesTheEventBus() { 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()); + } + } + // -- Mutual dependency -- public interface Ping extends Service { From de2e89fdc092b59c7da012e3cdcc98cd3c8bc78e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 20:20:39 -0500 Subject: [PATCH 23/59] scijava-context: @EventHandler scanning The container turns annotated methods into subscriptions, which is what the two-tier design was protecting: the event bus itself stays reflection-free and needs no `opens` from anyone using it directly, while the container -- which already reflects, for injection -- provides the convenience. A service's handlers are subscribed when the context creates it and dropped when the context is disposed. A service's lifetime being the context's, the author writes no subscription or cleanup code at all, and needs no `opens` beyond the one @Dependency already requires. 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 -- the leak that weak references used to paper over. Context.subscribe(Object) returns the subscriptions for the caller to close. What a handler throws reaches the publisher unwrapped rather than as an InvocationTargetException, so the event bus's aggregated failures say something useful. scijava-context-test covers the case that matters: a service with a private handler, in a package opened only to the container, hearing events and falling silent when the context is disposed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 16 ++- docs/migration.md | 1 + .../src/main/java/module-info.java | 3 +- .../context/test/RecordingService.java | 40 +++++++ .../scijava/context/test/ShoutRecorded.java | 44 ++++++++ .../test/impl/DefaultRecordingService.java | 56 ++++++++++ .../context/test/EventHandlerTest.java | 79 ++++++++++++++ .../java/org/scijava/context/Context.java | 82 ++++++++++++++ .../org/scijava/context/EventHandler.java | 86 +++++++++++++++ .../java/org/scijava/context/ContextTest.java | 102 ++++++++++++++++++ 10 files changed, 506 insertions(+), 3 deletions(-) create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/RecordingService.java create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java create mode 100644 scijava-context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java create mode 100644 scijava-context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java create mode 100644 scijava-context/src/main/java/org/scijava/context/EventHandler.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 63fbeefe5..73696eee0 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -557,8 +557,20 @@ No application context required by anything in this phase. 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. - - Still to do here: `@EventHandler` scanning, the one-way legacy bridge, and - the core services themselves. + - **`@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 one-way legacy bridge, and the core services + themselves. - **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 diff --git a/docs/migration.md b/docs/migration.md index 39e1cecdf..1d2eff98a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -104,6 +104,7 @@ A **dropped** row is a successful outcome, not a gap. | `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 | diff --git a/scijava-context-test/src/main/java/module-info.java b/scijava-context-test/src/main/java/module-info.java index 169716772..8e1de8a7d 100644 --- a/scijava-context-test/src/main/java/module-info.java +++ b/scijava-context-test/src/main/java/module-info.java @@ -39,6 +39,7 @@ requires org.scijava.discovery; provides org.scijava.context.Service with - org.scijava.context.test.impl.HiddenGreeter; + org.scijava.context.test.impl.HiddenGreeter, + org.scijava.context.test.impl.DefaultRecordingService; } diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/RecordingService.java b/scijava-context-test/src/main/java/org/scijava/context/test/RecordingService.java new file mode 100644 index 000000000..c57703c05 --- /dev/null +++ b/scijava-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/scijava-context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java b/scijava-context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java new file mode 100644 index 000000000..8c1fb04f7 --- /dev/null +++ b/scijava-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/scijava-context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java b/scijava-context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java new file mode 100644 index 000000000..a10d2f094 --- /dev/null +++ b/scijava-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/scijava-context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java b/scijava-context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java new file mode 100644 index 000000000..04cd3cbdb --- /dev/null +++ b/scijava-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/scijava-context/src/main/java/org/scijava/context/Context.java b/scijava-context/src/main/java/org/scijava/context/Context.java index 928e1cf1a..320e25171 100644 --- a/scijava-context/src/main/java/org/scijava/context/Context.java +++ b/scijava-context/src/main/java/org/scijava/context/Context.java @@ -30,6 +30,8 @@ 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; @@ -45,6 +47,7 @@ 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; @@ -156,6 +159,9 @@ public Optional optionalService(final Class type) { // 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 @@ -219,6 +225,34 @@ public void inject(final Object target) { } } + /** + * 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; @@ -310,6 +344,54 @@ private void injectField(final Object target, final Field field, } } + /** 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; diff --git a/scijava-context/src/main/java/org/scijava/context/EventHandler.java b/scijava-context/src/main/java/org/scijava/context/EventHandler.java new file mode 100644 index 000000000..83fea315d --- /dev/null +++ b/scijava-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/scijava-context/src/test/java/org/scijava/context/ContextTest.java b/scijava-context/src/test/java/org/scijava/context/ContextTest.java index a57fcb60e..4f4e821f3 100644 --- a/scijava-context/src/test/java/org/scijava/context/ContextTest.java +++ b/scijava-context/src/test/java/org/scijava/context/ContextTest.java @@ -42,6 +42,8 @@ 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; /** @@ -270,6 +272,106 @@ public void testMissingRequiredDependency() { } } + // -- 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 { From 2e0121389af2542c5b8f8016cacf57d2db2f111e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 23:12:13 -0500 Subject: [PATCH 24/59] docs: scrutinize the core services, and settle the preferences design The plan 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: StatusService is superseded by scijava-progress, ThreadService is mostly EDT dispatch and so belongs with the UI layer, AppService is largely metadata that scijava-meta already covers, and ObjectService should land with the widgets and conversion layer that use it, so that a consumer shapes it. PrefService is two things. Remembering what a user last typed into a given parameter of a given module is policy, and means nothing without the input harvester, so it goes to Phase 4. A settings store is a general capability whose API needs no consumer to get right, so it is deferred but its design is settled now, and recorded: not java.util.prefs, whose opacity is the actual complaint; YAML, since snakeyaml is already here and unlike JSON supports comments; $XDG_CONFIG_HOME/fiji or ~/.config/fiji on every platform, printable at runtime; namespaced by class; no migration from the old store. One question is genuinely blocked on the harvester and is recorded as open: stale keys accumulate for modules that no longer exist, and the harvester is what knows which are still live. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 49 ++++++++++++++++++++++++++++++++++++++++++ docs/migration.md | 5 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 73696eee0..68a2bdd45 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -700,6 +700,55 @@ subsystems, with no dependency on external `scijava-` 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. +- **YAML.** `snakeyaml` is already in this stack for op declarations, so it is + no new dependency, and unlike JSON it supports **comments**, which a + hand-editable file wants. Load with the safe constructor and quote scalars + on write, to avoid implicit typing turning `no` into `false` and version + strings into numbers. +- **`$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`** (in the incubator): polymorphic serialization where diff --git a/docs/migration.md b/docs/migration.md index 1d2eff98a..19f786f5e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -116,7 +116,10 @@ A **dropped** row is a successful outcome, not a gap. | `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`, `prefs`, `app` | `org.scijava.context` core services (Phase 2) | planned | +| `object` | with the widgets and conversion layer that use it (Phase 3/4), so a consumer shapes it | planned | +| `prefs` | split: a YAML settings store under `~/.config/fiji` (design settled in [migration-plan.md](migration-plan.md)), and widget value persistence, which belongs with the input harvester (Phase 4). Explicitly **not** `java.util.prefs` | planned | +| `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` | `scijava-convert3` (Phase 2) | planned | | `module`, `module.process`, `command` | execution layer on `scijava-struct` (Phase 3) | planned | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | From 9156ef673e7d3c43df20361b818f9b230c7eb54b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 23:17:16 -0500 Subject: [PATCH 25/59] docs: use TOML rather than YAML for the settings store TOML reads like the INI files people already understand, supports comments, and -- unlike YAML -- is neither whitespace-fragile nor implicitly typed, so `no` stays the string `no` and `1.10` stays `1.10`. Those 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, since the JDK has no TOML parser where snakeyaml was already present; the library is worth choosing when this is actually built. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 13 ++++++++----- docs/migration.md | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 68a2bdd45..d96e4d0fc 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -730,11 +730,14 @@ 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. -- **YAML.** `snakeyaml` is already in this stack for op declarations, so it is - no new dependency, and unlike JSON it supports **comments**, which a - hand-editable file wants. Load with the safe constructor and quote scalars - on write, to avoid implicit typing turning `no` into `false` and version - strings into numbers. +- **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 diff --git a/docs/migration.md b/docs/migration.md index 19f786f5e..41a622be9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -117,7 +117,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | split: a YAML settings store under `~/.config/fiji` (design settled in [migration-plan.md](migration-plan.md)), and widget value persistence, which belongs with the input harvester (Phase 4). Explicitly **not** `java.util.prefs` | planned | +| `prefs` | split: a TOML settings store under `~/.config/fiji` (design settled in [migration-plan.md](migration-plan.md)), and widget value persistence, which belongs with the input harvester (Phase 4). Explicitly **not** `java.util.prefs` | planned | | `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` | `scijava-convert3` (Phase 2) | planned | From 634d94047805ae516d8154bda284141f25465462 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 16 Sep 2026 23:24:16 -0500 Subject: [PATCH 26/59] scijava-execute: run things that declare their inputs and outputs Phase 3 begins. @Parameter on fields, a MemberParser that reads them (inherited ones included), and Executables.run, which binds inputs by name and hands back outputs by name -- SciJava Common's module layer, rebuilt on scijava-struct. @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 the two is what made the original annotation confusing. Cancellation needed nothing of its own, as the design predicted: a run is a Callable for an executor, and cancelling the Future interrupts it. The test cancels a sleeping task and checks it did not reach the end. Two bugs the tests caught: - FieldParameterMember inherited Member's read-only createInstance, so every input binding threw UnsupportedOperationException. It must return a ValueAccessibleMemberInstance to be writable. - The required-input check tested whether the field was null, which silently passes for primitives: a missing required double would have run as 0.0 and produced a wrong answer rather than an error. A required input must now be supplied, not merely non-null. On lifting the member parsers out of ops-engine, which the plan called for: inspecting ops.engine.struct, only about five of nineteen classes are framework-neutral. The parsers reach into Op-specific exceptions, Op-specific annotations (@Nullable and @OpDependency, in the already released scijava-ops-spi) and ops.engine.util helpers, so lifting them means first deciding where an optional-parameter annotation lives and then changing a released module. Better to build this layer first and let a second real consumer show what is genuinely shared. The plan records the specific couplings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 44 +++- pom.xml | 1 + scijava-execute/pom.xml | 143 ++++++++++++ .../src/main/java/module-info.java | 37 +++ .../java/org/scijava/execute/Executables.java | 166 ++++++++++++++ .../scijava/execute/FieldParameterMember.java | 131 +++++++++++ .../execute/FieldParameterMemberParser.java | 63 +++++ .../java/org/scijava/execute/Parameter.java | 82 +++++++ .../org/scijava/execute/ExecutablesTest.java | 215 ++++++++++++++++++ 9 files changed, 871 insertions(+), 11 deletions(-) create mode 100644 scijava-execute/pom.xml create mode 100644 scijava-execute/src/main/java/module-info.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Executables.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Parameter.java create mode 100644 scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index d96e4d0fc..03d72a6b7 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -605,17 +605,39 @@ No application context required by anything in this phase. ### Phase 3 — Execution layer -- **Factor the framework-neutral member parsing out of `scijava-ops-engine`** - into `scijava-struct` (or a `scijava-struct-reflect` beside it): - `ClassParameterMemberParser`, `Field`/`MethodParameterMemberParser`, - `FunctionalParameters`, `SynthesizedParameterMember`. The Op-specific pieces - (`OpDependency` members, retyping/resizing) stay in `ops-engine`. The - execution layer must **not** depend on `ops-engine`; if a dependency is - useful it runs the other way, `ops-engine` → execution layer. -- **Execution module** (name TBD): pre/postprocessor chains, input resolution, - a `@Parameter` equivalent, and a runner — built on `scijava-struct`. - Cancellation needs nothing here: the runner hands back a `Future`, and - cancelling it interrupts the worker. +- **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. + - Still to do here: the pre/postprocessor chain, and 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 diff --git a/pom.xml b/pom.xml index 51729bdb4..f01db2268 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,7 @@ scijava-discovery scijava-discovery-test scijava-events + scijava-execute scijava-function scijava-index scijava-io3 diff --git a/scijava-execute/pom.xml b/scijava-execute/pom.xml new file mode 100644 index 000000000..6ffe27723 --- /dev/null +++ b/scijava-execute/pom.xml @@ -0,0 +1,143 @@ + + + 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. + + + 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + org.scijava + scijava-common3 + ${scijava-common3.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/scijava-execute/src/main/java/module-info.java b/scijava-execute/src/main/java/module-info.java new file mode 100644 index 000000000..79afbd89c --- /dev/null +++ b/scijava-execute/src/main/java/module-info.java @@ -0,0 +1,37 @@ +/* + * #%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.struct; + +} diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java new file mode 100644 index 000000000..e2837cc99 --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -0,0 +1,166 @@ +/* + * #%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.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; + +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 inputs and outputs of the given class. */ + public static Struct struct(final Class type) { + return Structs.from(type, type, PARSER); + } + + /** + * 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(executable, inputs); + 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. */ + private static StructInstance bind(final Object executable, + final Map inputs) + { + final Struct struct = struct(executable.getClass()); + final StructInstance instance = struct.createInstance(executable); + + 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(inputs.get(key)); + } + else if (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. */ + private 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(); + } + + private 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/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java new file mode 100644 index 000000000..76d0e41a9 --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -0,0 +1,131 @@ +/* + * #%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.Field; +import java.lang.reflect.Type; + +import org.scijava.common3.Types; +import org.scijava.struct.ItemIO; +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 Member, ValueAccessible { + + private final Field field; + private final Parameter parameter; + private final Type itemType; + + public FieldParameterMember(final Field field, final Type structType) { + 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); + } + + /** Gets the label to display, falling back to the field name. */ + public String label() { + return parameter.label().isEmpty() ? key() : parameter.label(); + } + + @Override + @SuppressWarnings("unchecked") + public T get(final Object o) { + try { + field.setAccessible(true); + return (T) field.get(o); + } + catch (final RuntimeException | IllegalAccessException exc) { + throw new IllegalStateException(cannotAccess(o), exc); + } + } + + @Override + public void set(final T value, final Object o) { + try { + field.setAccessible(true); + field.set(o, value); + } + catch (final RuntimeException | IllegalAccessException exc) { + throw new IllegalStateException(cannotAccess(o), exc); + } + } + + private String cannotAccess(final Object o) { + // NB: the same qualified-opens requirement as everywhere else that + // reflects into user classes. + return "Cannot access " + field.getDeclaringClass().getName() + "." + // + field.getName() + ". Does its module declare `opens " + field + .getDeclaringClass().getPackageName() + " to org.scijava.execute;`?"; + } +} diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java new file mode 100644 index 000000000..f7fccacf7 --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java @@ -0,0 +1,63 @@ +/* + * #%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.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> +{ + + @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)); + } + } + return members; + } +} diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java new file mode 100644 index 000000000..836b3c07b --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java @@ -0,0 +1,82 @@ +/* + * #%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; + + /** A human-readable label, for display. */ + String label() default ""; + + /** A longer human-readable description. */ + String description() default ""; +} diff --git a/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java b/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java new file mode 100644 index 000000000..03aa44b4f --- /dev/null +++ b/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java @@ -0,0 +1,215 @@ +/* + * #%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()); + } +} From 1e1e6629defb1e932a328ac6266bcc3999ebba2e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 11:03:13 -0500 Subject: [PATCH 27/59] scijava-execute: the pre/postprocessor chain Runner.run is always asynchronous, returning Future. One verb covers both cases, since a caller wanting to block writes run(...).get() -- as SciJava Common's ModuleService has long shown. Declining is not cancellation, and the two now have different shapes. A preprocessor may stop a run before it happens, because the user closed a dialog or a precondition is unmet; that is an ordinary outcome and comes back as a result carrying reason() and declinedBy(). Interrupting work already under way remains Future.cancel(true), surfacing as CancellationException. A test asserts both, side by side. ExecutionResult is deliberately not itself a Future: isCancelled() and get() are coupled by contract, so a declined run modelled as a cancelled future would be an exception again, which returning a result was meant to avoid. The asynchrony lives in the future, what happened lives in the result. declinedBy() returns the preprocessor instance rather than its class, since a chain may hold two instances of one class -- there is a test with exactly that, a permissive and a strict Gate, asserting the result names the one that objected. Declining lives on the execution rather than the preprocessor. SciJava Common put isCanceled()/getCancelReason() on the plugin, which made preprocessors stateful and unsafe to share between runs; that matters more here, since Context constructs a fresh plugin instance per call. Telling the execution keeps preprocessors stateless and gives declinedBy() the right instance for free. Required-input checking moves from binding time to after preprocessing, since supplying values the caller omitted is precisely what a preprocessor such as an input harvester is for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 29 +- docs/migration.md | 3 +- scijava-execute/pom.xml | 6 + .../src/main/java/module-info.java | 1 + .../java/org/scijava/execute/Executables.java | 20 +- .../java/org/scijava/execute/Execution.java | 83 ++++++ .../org/scijava/execute/ExecutionResult.java | 110 ++++++++ .../org/scijava/execute/Postprocessor.java | 53 ++++ .../org/scijava/execute/Preprocessor.java | 65 +++++ .../main/java/org/scijava/execute/Runner.java | 232 ++++++++++++++++ .../java/org/scijava/execute/RunnerTest.java | 249 ++++++++++++++++++ 11 files changed, 840 insertions(+), 11 deletions(-) create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Execution.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/ExecutionResult.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Postprocessor.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Preprocessor.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Runner.java create mode 100644 scijava-execute/src/test/java/org/scijava/execute/RunnerTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 03d72a6b7..9e30f9207 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -635,9 +635,32 @@ No application context required by anything in this phase. - **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. - - Still to do here: the pre/postprocessor chain, and factoring the - framework-neutral member parsing out of `ops-engine` — which is a larger - job than it looks, see below. + - **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 diff --git a/docs/migration.md b/docs/migration.md index 41a622be9..d18cf3795 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -121,7 +121,8 @@ A **dropped** row is a successful outcome, not a gap. | `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` | `scijava-convert3` (Phase 2) | planned | -| `module`, `module.process`, `command` | execution layer on `scijava-struct` (Phase 3) | planned | +| `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` | still to come: a command is a struct plus menu metadata plus `run` | planned | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `widget` | input harvesting on `scijava-struct`, with dynamic subgroups (Phase 4) | planned | | `ui`, `menu` | minimal UI API; `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` dropped (Phase 4) | planned | diff --git a/scijava-execute/pom.xml b/scijava-execute/pom.xml index 6ffe27723..6ca455ab7 100644 --- a/scijava-execute/pom.xml +++ b/scijava-execute/pom.xml @@ -87,6 +87,7 @@ 1.0.1-SNAPSHOT + 1.0.1-SNAPSHOT 1.0.1-SNAPSHOT @@ -96,6 +97,11 @@ scijava-common3 ${scijava-common3.version}
+ + org.scijava + scijava-priority + ${scijava-priority.version} + org.scijava scijava-struct diff --git a/scijava-execute/src/main/java/module-info.java b/scijava-execute/src/main/java/module-info.java index 79afbd89c..96627f280 100644 --- a/scijava-execute/src/main/java/module-info.java +++ b/scijava-execute/src/main/java/module-info.java @@ -32,6 +32,7 @@ exports org.scijava.execute; requires org.scijava.common3; + requires transitive org.scijava.priority; requires transitive org.scijava.struct; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java index e2837cc99..d74cf56d1 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Executables.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -85,7 +85,7 @@ public static Struct struct(final Class type) { public static Map run(final Runnable executable, final Map inputs) { - final StructInstance instance = bind(executable, inputs); + final StructInstance instance = bind(executable, inputs, true); checkCancellation(); executable.run(); checkCancellation(); @@ -106,9 +106,15 @@ public static Callable> callable( return () -> run(executable, inputs); } - /** Binds the given input values onto the object's parameters. */ - private static StructInstance bind(final Object executable, - final Map 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 Object executable, + final Map inputs, final boolean requireInputs) { final Struct struct = struct(executable.getClass()); final StructInstance instance = struct.createInstance(executable); @@ -125,7 +131,7 @@ private static StructInstance bind(final Object executable, if (inputs.containsKey(key)) { member.set(inputs.get(key)); } - else if (member.member().isRequired()) { + 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. @@ -137,7 +143,7 @@ else if (member.member().isRequired()) { } /** Collects the object's output values. */ - private static Map outputs(final StructInstance instance) { + 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(), // @@ -155,7 +161,7 @@ private static String names(final Struct struct) { return sb.length() == 0 ? "no parameters" : sb.toString(); } - private static void checkCancellation() { + static void checkCancellation() { // NB: cancellation is thread interruption; restore the flag so callers // further up still see it. if (Thread.interrupted()) { diff --git a/scijava-execute/src/main/java/org/scijava/execute/Execution.java b/scijava-execute/src/main/java/org/scijava/execute/Execution.java new file mode 100644 index 000000000..f8274984a --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/Execution.java @@ -0,0 +1,83 @@ +/* + * #%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 object being run. */ + Object 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 the outputs produced so far. Empty until the object has run, so this + * is for postprocessors. + */ + Map outputs(); +} diff --git a/scijava-execute/src/main/java/org/scijava/execute/ExecutionResult.java b/scijava-execute/src/main/java/org/scijava/execute/ExecutionResult.java new file mode 100644 index 000000000..344f82a53 --- /dev/null +++ b/scijava-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/scijava-execute/src/main/java/org/scijava/execute/Postprocessor.java b/scijava-execute/src/main/java/org/scijava/execute/Postprocessor.java new file mode 100644 index 000000000..7a7ab6b3f --- /dev/null +++ b/scijava-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/scijava-execute/src/main/java/org/scijava/execute/Preprocessor.java b/scijava-execute/src/main/java/org/scijava/execute/Preprocessor.java new file mode 100644 index 000000000..153f4c313 --- /dev/null +++ b/scijava-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/scijava-execute/src/main/java/org/scijava/execute/Runner.java b/scijava-execute/src/main/java/org/scijava/execute/Runner.java new file mode 100644 index 000000000..2b284e74b --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/Runner.java @@ -0,0 +1,232 @@ +/* + * #%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.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 object: preprocess, run, postprocess. + * + * @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 executor.submit(() -> execute(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 Runnable executable, + final Map inputs) + { + final DefaultExecution execution = new DefaultExecution(executable, // + Executables.bind(executable, inputs, false)); + + 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(); + executable.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 Object executable; + private final StructInstance instance; + private String reason; + private boolean declined; + private Map outputs = Map.of(); + + DefaultExecution(final Object executable, + final StructInstance instance) + { + this.executable = executable; + this.instance = instance; + } + + void complete(final Map outputs) { + this.outputs = outputs; + } + + @Override + public Object executable() { + return executable; + } + + @Override + public StructInstance instance() { + return instance; + } + + @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/scijava-execute/src/test/java/org/scijava/execute/RunnerTest.java b/scijava-execute/src/test/java/org/scijava/execute/RunnerTest.java new file mode 100644 index 000000000..2cd0670d9 --- /dev/null +++ b/scijava-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)); + } +} From 95b4e5706632070a06e7484fc80ec967a420ff65 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 11:28:13 -0500 Subject: [PATCH 28/59] docs: say how Fiji gets built on SciJava3 The plan kept saying "drop" about the application shell without saying where it goes instead, which reads as though Fiji loses its menus. It does not: the problem with SciJava Common was never that it had menus, but that the core had them, so a library wanting only plugin discovery inherited MenuPath, Display and a transitive AWT flavour. Adds a layering section: foundation (built) / application model / toolkit bindings / application, with only the bindings importing AWT or Swing. All of the presentation metadata survives -- menu path, label, icon, accelerator, weight, selection group, visibility, enablement -- since Fiji's menus are built from it. What changes is that it becomes attributes in the annotation index rather than the BasicDetails/UIDetails/AbstractUIDetails inheritance chain every plugin extends. Notes that the expensive part is already solved: SciJava Common builds its menu from ModuleInfo, naming classes without loading them and loading on demand, which is exactly the implClassName()/type()/get() split of Discovery. The other seams -- harvesting as a Preprocessor, displays as a Postprocessor, input events with Consumable -- were built in phases 1-3 with this in mind. Also records that the real risk is migration rather than architecture: hundreds of commands across dozens of repositories cannot update on a flag day, so the one-way legacy bridge matters more than its deferral implies and should be sequenced with layer 1 rather than after it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 92 ++++++++++++++++++++++++++++++++++++++++-- docs/migration.md | 4 +- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 9e30f9207..91c2464b8 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -674,6 +674,90 @@ No application context required by anything in this phase. `#@` 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** (to build) | `scijava-command` (a struct plus presentation metadata), `scijava-menu` (the menu tree), `scijava-ui-api` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | +| **2. Toolkit bindings** | `scijava-ui-swing`, 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 deferral 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, and it should be sequenced *with* layer 1 rather than +after it. + ### Phase 4 — UI and desktop - **Input harvesting is a headline deliverable**, not a port. It is the @@ -684,8 +768,10 @@ No application context required by anything in this phase. 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. -- **Drop** the opinionated application shell: `ApplicationFrame`, `Desktop`, - `StatusBar`, `ToolBar`. The SJ3 UI API is minimal. +- **The application shell moves to layers 1 and 2**, it is not discarded: + `ApplicationFrame`, `Desktop`, `StatusBar` and `ToolBar` are contracts in + `scijava-ui-api` 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 @@ -718,7 +804,7 @@ No application context required by anything in this phase. | `module`, `command` | → execution layer (Phase 3) | | `script` | → scripting facade + adapters (Phase 3) | | `widget` | → input harvesting, redesigned (Phase 4) | -| `ui`, `menu` | Minimal UI API; app shell dropped (Phase 4) | +| `ui`, `menu` | `scijava-ui-api` 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) | diff --git a/docs/migration.md b/docs/migration.md index d18cf3795..344a29f27 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,7 +86,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | plugin/module metadata (Phases 2–4) | open | +| `BasicDetails`, `AbstractBasicDetails`, `UIDetails`, `AbstractUIDetails`, `MenuEntry`, `MenuPath` | `scijava-command` presentation metadata (layer 1). All of it survives — menu path, label, icon, accelerator, weight, selection group, visibility, enablement — but as attributes in the annotation index rather than an inheritance chain every plugin extends | planned | | `Validated`, `ValidityProblem` | execution layer (Phase 3) | open | | `Optional` | `scijava-optional` (satellite repo, moves in later) | open | @@ -125,7 +125,7 @@ A **dropped** row is a successful outcome, not a gap. | `command` | still to come: a command is a struct plus menu metadata plus `run` | planned | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `widget` | input harvesting on `scijava-struct`, with dynamic subgroups (Phase 4) | planned | -| `ui`, `menu` | minimal UI API; `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` dropped (Phase 4) | planned | +| `ui`, `menu` | `scijava-ui-api` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | From 01387352b8c631183878ab4e3287d75d6ab0e159 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 12:51:18 -0500 Subject: [PATCH 29/59] scijava-execute: generalize beyond Java classes, for scripts Executable and ExecutableInstance are SciJava Common's ModuleInfo and Module, renamed because "module" now means a JPMS module and this codebase is full of module-info.java files. The point of the change is that Executable mentions no Java reflection: it is name(), struct() and create(). A script declares its parameters in its header and holds their values in engine bindings, with no @Parameter field anywhere, and must still reach the menu, the harvester and the runner by the same path as a Java command. Building this layer on Runnable, as it was, would have forced scripts into a parallel mechanism bolted on later. JavaExecutable is now one implementation among others: the case where parameters happen to be annotated fields. Runner.run keeps a Runnable overload as sugar. A test stands in for a script -- parameters from a list, values in a Map, run() reading and writing those bindings -- and runs it through the same Runner with the same preprocessors, for the same ExecutionResult. Another asserts that two runs get independent bindings, the concurrency trap that treating the executable as its own instance would invite. Also adds an index test proving that two indexable annotations on one class are indexed independently, each to its own file. That is what lets presentation metadata stack onto a plugin declaration as @Plugin plus @Menu, rather than every plugin type's attributes accumulating in one annotation -- and it needs no new machinery, since the processor already indexes anything meta-annotated @Indexable. The test also needed the component's own classes added to the compile path explicitly: these tests run on the module path, so java.class.path alone does not contain them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- .../java/org/scijava/execute/Executable.java | 68 ++++++ .../scijava/execute/ExecutableInstance.java | 53 +++++ .../java/org/scijava/execute/Executables.java | 65 +++++- .../org/scijava/execute/JavaExecutable.java | 100 +++++++++ .../main/java/org/scijava/execute/Runner.java | 42 ++-- .../execute/ScriptLikeExecutableTest.java | 211 ++++++++++++++++++ .../index/AnnotationProcessorTest.java | 86 +++++++ 7 files changed, 608 insertions(+), 17 deletions(-) create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Executable.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java create mode 100644 scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executable.java b/scijava-execute/src/main/java/org/scijava/execute/Executable.java new file mode 100644 index 000000000..780e49a07 --- /dev/null +++ b/scijava-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/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java b/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java new file mode 100644 index 000000000..ba6ad0727 --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.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.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(); +} diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java index d74cf56d1..415cf00b1 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Executables.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -59,6 +59,63 @@ 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 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) { + final Struct struct = struct(object.getClass()); + 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); + } + + @Override + public StructInstance parameters() { + return parameters; + } + + @Override + public void run() { + object.run(); + } + }; + } + }; + } + /** Describes the inputs and outputs of the given class. */ public static Struct struct(final Class type) { return Structs.from(type, type, PARSER); @@ -85,7 +142,8 @@ public static Struct struct(final Class type) { public static Map run(final Runnable executable, final Map inputs) { - final StructInstance instance = bind(executable, inputs, true); + final StructInstance instance = bind(struct(executable.getClass()) + .createInstance(executable), inputs, true); checkCancellation(); executable.run(); checkCancellation(); @@ -113,11 +171,10 @@ public static Callable> callable( * now. A {@link Runner} passes false, since a preprocessor may * supply them later. */ - static StructInstance bind(final Object executable, + static StructInstance bind(final StructInstance instance, final Map inputs, final boolean requireInputs) { - final Struct struct = struct(executable.getClass()); - final StructInstance instance = struct.createInstance(executable); + final Struct struct = instance.struct(); for (final String key : inputs.keySet()) { if (instance.member(key) == null) { diff --git a/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java b/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java new file mode 100644 index 000000000..fe2aed28d --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java @@ -0,0 +1,100 @@ +/* + * #%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; +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; + + public JavaExecutable(final Class type) { + this.type = type; + this.struct = Executables.struct(type); + } + + @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(); + } + }; + } + + /** Gets the class this describes. */ + public Class type() { + return type; + } + + 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/scijava-execute/src/main/java/org/scijava/execute/Runner.java b/scijava-execute/src/main/java/org/scijava/execute/Runner.java index 2b284e74b..ac25036d1 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Runner.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Runner.java @@ -93,7 +93,22 @@ public static Runner of(final List preprocessors, } /** - * Runs the given object: preprocess, run, postprocess. + * 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 @@ -102,7 +117,7 @@ public static Runner of(final List preprocessors, public Future run(final Runnable executable, final Map inputs) { - return executor.submit(() -> execute(executable, inputs)); + return run(Executables.executableOf(executable), inputs); } /** Gets the preprocessors, highest priority first. */ @@ -117,11 +132,12 @@ public List postprocessors() { // -- Helper methods -- - private ExecutionResult execute(final Runnable executable, + private ExecutionResult execute(final Executable executable, final Map inputs) { - final DefaultExecution execution = new DefaultExecution(executable, // - Executables.bind(executable, inputs, false)); + final ExecutableInstance instance = executable.create(); + final DefaultExecution execution = new DefaultExecution(instance, // + Executables.bind(instance.parameters(), inputs, false)); for (final Preprocessor preprocessor : preprocessors) { Executables.checkCancellation(); @@ -138,7 +154,7 @@ private ExecutionResult execute(final Runnable executable, checkRequiredInputs(execution.instance()); Executables.checkCancellation(); - executable.run(); + instance.run(); Executables.checkCancellation(); execution.complete(Executables.outputs(execution.instance())); @@ -181,17 +197,17 @@ private static ExecutorService daemonExecutor() { /** The execution handed to each processor. */ private static class DefaultExecution implements Execution { - private final Object executable; - private final StructInstance instance; + private final ExecutableInstance instance; + private final StructInstance parameters; private String reason; private boolean declined; private Map outputs = Map.of(); - DefaultExecution(final Object executable, - final StructInstance instance) + DefaultExecution(final ExecutableInstance instance, + final StructInstance parameters) { - this.executable = executable; this.instance = instance; + this.parameters = parameters; } void complete(final Map outputs) { @@ -200,12 +216,12 @@ void complete(final Map outputs) { @Override public Object executable() { - return executable; + return instance; } @Override public StructInstance instance() { - return instance; + return parameters; } @Override diff --git a/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java b/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java new file mode 100644 index 000000000..be0cf6e2a --- /dev/null +++ b/scijava-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(((ExecutableInstance) 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-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java b/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java index 41335709a..0a481d221 100644 --- a/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java +++ b/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java @@ -113,6 +113,92 @@ public void testProcessorWritesIndex(@TempDir final Path outputDir) 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 { From 0e4ac38365779f288abe1ac47a8f371df11f69ac Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 12:58:05 -0500 Subject: [PATCH 30/59] scijava-command: discoverable commands, and the metadata menus need A command is a Runnable with declared parameters, discovered with @Plugin(type = Command.class) and placed in the menus with a stacked @Menu. Keeping those separate is what stops @Plugin accumulating attributes that make no sense for most plugin types -- a converter has no menu path -- while leaving @Plugin the single uniform way to declare a plugin, so "is a command a plugin?" still answers yes. Stacking needed no new indexing machinery: the processor indexes anything meta-annotated @Indexable, one file per annotation, so @Menu gets its own index and Commands.discover joins the two on the class name. No bytecode parser, and no class loading -- a test asserts a menu is built with path, label, accelerator, icon, weight and visibility while constructing nothing. CommandInfo implements Executable, so a discovered command reaches the runner and the processors by the same path a script does; the script-shaped test in scijava-execute shares that path. "Command" here means what SciJava Common called a module: anything runnable with declared parameters. A script is a command whose implementation is source in some language. Module is retired, Java having taken the word. Two things worth noting: - A command's package must be opened to two modules: the context constructs the command and the execution layer reads its @Parameter fields. Whether to consolidate that is recorded as an open question, since it is a line many people will have to write. - The "menus construct nothing" assertion first used a static flag on a command another test runs, and JUnit does not order methods by declaration, so it passed or failed by luck. It now asserts against a command no test runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- pom.xml | 1 + scijava-command/pom.xml | 185 ++++++++++++++++++ .../src/main/java/module-info.java | 47 +++++ .../java/org/scijava/command/Command.java | 73 +++++++ .../java/org/scijava/command/CommandInfo.java | 160 +++++++++++++++ .../java/org/scijava/command/Commands.java | 107 ++++++++++ .../main/java/org/scijava/command/Menu.java | 100 ++++++++++ .../org/scijava/command/CommandsTest.java | 169 ++++++++++++++++ .../java/org/scijava/command/CountBeans.java | 51 +++++ .../java/org/scijava/command/Headless.java | 50 +++++ .../java/org/scijava/command/NeverRun.java | 61 ++++++ .../java/org/scijava/command/SayHello.java | 59 ++++++ 12 files changed, 1063 insertions(+) create mode 100644 scijava-command/pom.xml create mode 100644 scijava-command/src/main/java/module-info.java create mode 100644 scijava-command/src/main/java/org/scijava/command/Command.java create mode 100644 scijava-command/src/main/java/org/scijava/command/CommandInfo.java create mode 100644 scijava-command/src/main/java/org/scijava/command/Commands.java create mode 100644 scijava-command/src/main/java/org/scijava/command/Menu.java create mode 100644 scijava-command/src/test/java/org/scijava/command/CommandsTest.java create mode 100644 scijava-command/src/test/java/org/scijava/command/CountBeans.java create mode 100644 scijava-command/src/test/java/org/scijava/command/Headless.java create mode 100644 scijava-command/src/test/java/org/scijava/command/NeverRun.java create mode 100644 scijava-command/src/test/java/org/scijava/command/SayHello.java diff --git a/pom.xml b/pom.xml index f01db2268..796954d68 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ scijava-collections + scijava-command scijava-common3 scijava-context scijava-context-test diff --git a/scijava-command/pom.xml b/scijava-command/pom.xml new file mode 100644 index 000000000..268907ef2 --- /dev/null +++ b/scijava-command/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-command + 1.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.command + + 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/scijava-command/src/main/java/module-info.java b/scijava-command/src/main/java/module-info.java new file mode 100644 index 000000000..09eb9e3e5 --- /dev/null +++ b/scijava-command/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.command { + + exports org.scijava.command; + + // NB: a command's package must be opened to both modules that reflect into + // it: the context constructs the command, and the execution layer reads and + // writes its @Parameter fields. `opens` is not `exports` -- the classes stay + // invisible to ordinary callers either way. + opens org.scijava.command to org.scijava.context, org.scijava.execute; + + 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/scijava-command/src/main/java/org/scijava/command/Command.java b/scijava-command/src/main/java/org/scijava/command/Command.java new file mode 100644 index 000000000..6816f2fb5 --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/Command.java @@ -0,0 +1,73 @@ +/* + * #%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.command; + +/** + * 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 + * runnable 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 Java. Neither is a special case of the + * other, and both reach the menu, the harvester and the runner by one path - + * see {@code org.scijava.execute.Executable}, which mentions no Java + * reflection at all. + *

+ *

+ * 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/scijava-command/src/main/java/org/scijava/command/CommandInfo.java b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java new file mode 100644 index 000000000..4a9911845 --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java @@ -0,0 +1,160 @@ +/* + * #%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.command; + +import java.util.Map; +import java.util.Optional; + +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 CommandInfo} 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. + *

+ * + * @author Curtis Rueden + */ +public class CommandInfo implements Executable { + + private final Discovery discovery; + private final Map menu; + private Executable delegate; + + CommandInfo(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. */ + public Optional menuPath() { + return attr("path"); + } + + /** Gets the label to display, defaulting to the last menu path element. */ + 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. */ + public Optional accelerator() { + return attr("accelerator"); + } + + /** Gets this command's icon resource, if it has one. */ + public Optional iconPath() { + return attr("iconPath"); + } + + /** Gets how this command sorts among its menu siblings. */ + public double weight() { + return attr("weight").map(Double::parseDouble) // + .orElse(Double.POSITIVE_INFINITY); + } + + /** Gets whether this command should appear in menus. */ + 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(); + delegate = Executables.of(type); + } + return delegate; + } +} diff --git a/scijava-command/src/main/java/org/scijava/command/Commands.java b/scijava-command/src/main/java/org/scijava/command/Commands.java new file mode 100644 index 000000000..fe271a2a8 --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/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.command; + +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 CommandInfo(discovery, menus.getOrDefault( // + discovery.implClassName(), Map.of()))); + } + commands.sort(Comparator.comparingDouble(CommandInfo::weight) // + .thenComparing(CommandInfo::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/scijava-command/src/main/java/org/scijava/command/Menu.java b/scijava-command/src/main/java/org/scijava/command/Menu.java new file mode 100644 index 000000000..0ac76722f --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/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.command; + +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/scijava-command/src/test/java/org/scijava/command/CommandsTest.java b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java new file mode 100644 index 000000000..8749f25a5 --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java @@ -0,0 +1,169 @@ +/* + * #%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.command; + +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.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(CommandInfo::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 CommandInfo 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 CommandInfo never = find(context, NeverRun.class); + assertEquals("Help>Never Run", never.menuPath().orElse(null)); + assertFalse(constructed("org.scijava.command.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()) { + final List labels = Commands.discover(context).stream() // + .filter(c -> c.menuPath().isPresent()) // + .map(CommandInfo::label) // + .collect(Collectors.toList()); + // Weights: CountBeans 3, SayHello 12, NeverRun 50. + assertEquals(List.of("Count Beans", "Say Hello...", "Never Run"), + labels); + } + } + + /** A command needs no menu entry; it is simply not shown. */ + @Test + public void testCommandWithoutAMenuEntry() { + try (final Context context = Context.create()) { + final CommandInfo 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 CommandInfo 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.command.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 CommandInfo 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()); + } + } + + private static CommandInfo 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); + } + } +} diff --git a/scijava-command/src/test/java/org/scijava/command/CountBeans.java b/scijava-command/src/test/java/org/scijava/command/CountBeans.java new file mode 100644 index 000000000..978e027e3 --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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/scijava-command/src/test/java/org/scijava/command/Headless.java b/scijava-command/src/test/java/org/scijava/command/Headless.java new file mode 100644 index 000000000..d81c853a9 --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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/scijava-command/src/test/java/org/scijava/command/NeverRun.java b/scijava-command/src/test/java/org/scijava/command/NeverRun.java new file mode 100644 index 000000000..e7117a066 --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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/scijava-command/src/test/java/org/scijava/command/SayHello.java b/scijava-command/src/test/java/org/scijava/command/SayHello.java new file mode 100644 index 000000000..e005a956c --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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; + } +} From 80bab691b922b3521dcd37492ea31ef44a33612e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 13:46:02 -0500 Subject: [PATCH 31/59] One opens, to the container A plugin author now writes `opens com.example to org.scijava.context;` and nothing more. Context.privateLookupIn(Class) hands out a lookup created inside the container, and the execution layer reads and writes @Parameter fields through it, since reflective access is checked against the lookup's module rather than the module calling. Two things measured rather than assumed, both of which would have been easy to get wrong: - privateLookupIn also requires the lookup's module to READ the target's module. A container never declares that -- plugins depend on it, not the reverse -- so it must add the edge at runtime with addReads, and only a module's own code may do that. That is the second reason this method has to live in Context. - The capability is scoped to one class. Exposing the container's general-purpose lookup would let any holder reflect into anything opened to the container; returning privateLookupIn(type, ...) instead yields a lookup limited to that class, and makes the execution layer's side simpler too. Field access moves from Field.setAccessible to a VarHandle obtained from the lookup, resolved once per member. A test asserts the command package is not open to org.scijava.execute, so that a regression names itself instead of surfacing as a puzzling access error at run time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- .../src/main/java/module-info.java | 10 +-- .../java/org/scijava/command/CommandInfo.java | 10 ++- .../java/org/scijava/command/Commands.java | 2 +- .../org/scijava/command/CommandsTest.java | 22 +++++++ scijava-context/.factorypath | 7 +++ .../java/org/scijava/context/Context.java | 47 ++++++++++++++ .../java/org/scijava/execute/Executables.java | 28 ++++++++- .../scijava/execute/FieldParameterMember.java | 63 ++++++++++++++----- .../execute/FieldParameterMemberParser.java | 17 ++++- .../org/scijava/execute/JavaExecutable.java | 16 ++++- 10 files changed, 194 insertions(+), 28 deletions(-) create mode 100644 scijava-context/.factorypath diff --git a/scijava-command/src/main/java/module-info.java b/scijava-command/src/main/java/module-info.java index 09eb9e3e5..0b644b892 100644 --- a/scijava-command/src/main/java/module-info.java +++ b/scijava-command/src/main/java/module-info.java @@ -31,11 +31,11 @@ exports org.scijava.command; - // NB: a command's package must be opened to both modules that reflect into - // it: the context constructs the command, and the execution layer reads and - // writes its @Parameter fields. `opens` is not `exports` -- the classes stay - // invisible to ordinary callers either way. - opens org.scijava.command to org.scijava.context, org.scijava.execute; + // 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.command to org.scijava.context; requires transitive org.scijava.context; requires transitive org.scijava.execute; diff --git a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java index 4a9911845..5dc15ae04 100644 --- a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java +++ b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.Optional; +import org.scijava.context.Context; import org.scijava.discovery.Discovery; import org.scijava.execute.Executable; import org.scijava.execute.ExecutableInstance; @@ -60,13 +61,15 @@ public class CommandInfo implements Executable { private final Discovery discovery; private final Map menu; + private final Context context; private Executable delegate; CommandInfo(final Discovery discovery, - final Map menu) + final Map menu, final Context context) { this.discovery = discovery; this.menu = menu; + this.context = context; } // -- Metadata, readable without loading the class -- @@ -153,7 +156,10 @@ public String toString() { private synchronized Executable delegate() { if (delegate == null) { final Class type = discovery.type(); - delegate = Executables.of(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, context.privateLookupIn(type)); } return delegate; } diff --git a/scijava-command/src/main/java/org/scijava/command/Commands.java b/scijava-command/src/main/java/org/scijava/command/Commands.java index fe271a2a8..293771ddb 100644 --- a/scijava-command/src/main/java/org/scijava/command/Commands.java +++ b/scijava-command/src/main/java/org/scijava/command/Commands.java @@ -71,7 +71,7 @@ public static List discover(final Context context) { final List commands = new ArrayList<>(); for (final Discovery discovery : context.plugins(Command.class)) { commands.add(new CommandInfo(discovery, menus.getOrDefault( // - discovery.implClassName(), Map.of()))); + discovery.implClassName(), Map.of()), context)); } commands.sort(Comparator.comparingDouble(CommandInfo::weight) // .thenComparing(CommandInfo::label)); diff --git a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java index 8749f25a5..8c7aedc29 100644 --- a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java +++ b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java @@ -148,6 +148,28 @@ public void testEachRunHasItsOwnInstance() throws Exception { } } + /** + * 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.command", context), + "commands must be open to the container"); + assertFalse(module.isOpen("org.scijava.command", execute), + "commands should not need opening to the execution layer as well"); + } + private static CommandInfo find(final Context context, final Class type) { return Commands.discover(context).stream() // .filter(c -> type.getName().equals(c.className())) // diff --git a/scijava-context/.factorypath b/scijava-context/.factorypath new file mode 100644 index 000000000..7d8d9414d --- /dev/null +++ b/scijava-context/.factorypath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/scijava-context/src/main/java/org/scijava/context/Context.java b/scijava-context/src/main/java/org/scijava/context/Context.java index 320e25171..924a451fb 100644 --- a/scijava-context/src/main/java/org/scijava/context/Context.java +++ b/scijava-context/src/main/java/org/scijava/context/Context.java @@ -29,6 +29,7 @@ package org.scijava.context; +import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -253,6 +254,52 @@ public List subscribe(final Object target) { return subscriptions; } + /** + * Gets a {@link java.lang.invoke.MethodHandles.Lookup} able to reflect into + * the given class, for a collaborator that cannot obtain one itself. + *

+ * This exists so that a plugin author writes one line rather than + * two: + *

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

+ * Reflective access is checked against the module performing it, so any + * other SciJava module that must read a plugin's fields - the execution + * layer, reading {@code @Parameter} values - would otherwise need its own + * {@code opens}. Handing it a lookup created here means the single + * {@code opens} to this module covers them all. + *

+ *

+ * NB: the returned lookup is scoped to {@code type} alone, not a + * general-purpose capability: its holder can reflect into that one class + * and nothing else. + *

+ *

+ * NB: {@code privateLookupIn} also requires this module to read the + * target's module, which a container never declares - plugins depend on it, + * not the reverse. A module may only add that edge from its own code, which + * is the other reason this method has to live here. + *

+ * + * @param type the class to be reflected into + * @return a lookup with private access to that class + * @throws ServiceException if the class's package is not open to this module + */ + public MethodHandles.Lookup privateLookupIn(final Class type) { + try { + Context.class.getModule().addReads(type.getModule()); + return MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } + catch (final IllegalAccessException exc) { + throw new ServiceException("Cannot reflect into " + type.getName() + // + ". Does its module declare `opens " + type.getPackageName() + // + " to org.scijava.context;`?", exc); + } + } + /** Gets this context's event bus. */ public EventBus events() { return events; diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java index 415cf00b1..846717c22 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Executables.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -69,6 +69,21 @@ 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. @@ -118,7 +133,18 @@ public void run() { /** Describes the inputs and outputs of the given class. */ public static Struct struct(final Class type) { - return Structs.from(type, type, PARSER); + 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)); } /** diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java index 76d0e41a9..a7a96ca14 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -29,6 +29,9 @@ 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; @@ -49,8 +52,31 @@ public class FieldParameterMember implements Member, 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) { @@ -101,31 +127,34 @@ public String label() { @Override @SuppressWarnings("unchecked") public T get(final Object o) { - try { - field.setAccessible(true); - return (T) field.get(o); - } - catch (final RuntimeException | IllegalAccessException exc) { - throw new IllegalStateException(cannotAccess(o), exc); - } + return (T) handle().get(o); } @Override public void set(final T value, final Object o) { - try { - field.setAccessible(true); - field.set(o, value); - } - catch (final RuntimeException | IllegalAccessException exc) { - throw new IllegalStateException(cannotAccess(o), exc); + 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 // + : MethodHandles.privateLookupIn(field.getDeclaringClass(), + MethodHandles.lookup()); + handle = access.unreflectVarHandle(field); + } + catch (final RuntimeException | IllegalAccessException exc) { + throw new IllegalStateException(cannotAccess(), exc); + } } + return handle; } - private String cannotAccess(final Object o) { - // NB: the same qualified-opens requirement as everywhere else that - // reflects into user classes. + private String cannotAccess() { return "Cannot access " + field.getDeclaringClass().getName() + "." + // field.getName() + ". Does its module declare `opens " + field - .getDeclaringClass().getPackageName() + " to org.scijava.execute;`?"; + .getDeclaringClass().getPackageName() + " to org.scijava.context;`?" + // + " A caller with no container must open to org.scijava.execute instead."; } } diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java index f7fccacf7..483cf175b 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java @@ -29,6 +29,7 @@ package org.scijava.execute; +import java.lang.invoke.MethodHandles.Lookup; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; @@ -46,6 +47,20 @@ 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(); @@ -55,7 +70,7 @@ public List> parse(final Class source, final Type structType) { 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)); + members.add(new FieldParameterMember<>(field, structType, lookup)); } } return members; diff --git a/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java b/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java index fe2aed28d..deea3fc4e 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java +++ b/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java @@ -29,6 +29,8 @@ package org.scijava.execute; +import java.lang.invoke.MethodHandles.Lookup; + import org.scijava.struct.Struct; import org.scijava.struct.StructInstance; @@ -44,8 +46,20 @@ public class JavaExecutable implements Executable { private final Struct struct; 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.struct = Executables.struct(type); + this.struct = Executables.struct(type, lookup); } @Override From cfead6d33331255b643aac4be9a2898ba4b4f559 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 13:52:28 -0500 Subject: [PATCH 32/59] Add the menu tree, and make Access a static utility MenuTree arranges commands into the tree a user interface renders: labels, nesting, and order by weight, where a submenu takes the weight of its lightest child so that submenus do not all fall back to alphabetical order. It is a model and nothing else -- SciJava Common's ShadowMenu was simultaneously a Collection, a Runnable and a Comparable, and carried a context. A test asserts that building the whole tree constructs no commands. Context.privateLookupIn becomes Access.lookupIn, a static utility. The module system cares about the module the code lives in, not whether the call is static, and the operation used no context state; the knock-on is that CommandInfo no longer needs a Context at all, so Commands.discover stopped threading one through. It still returns MethodHandles.Lookup rather than an accessor of our own. That is the JDK's token for delegated reflective access, it already spans fields, methods and constructors, and a bespoke facade would need extending every time a caller wanted access we had not anticipated. The harvester's parameter callbacks will be the first real test of whether call sites want wrapping; if they do, the wrapper belongs with that consumer. Also fixes testMenuOrdering, which asserted an exact whole-list order and broke as soon as the menu tests added fixtures -- the same brittleness fixed two commits ago in another test. Exact-list assertions over a discovered set are a standing trap here, since any test may add one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- .../java/org/scijava/command/CommandInfo.java | 8 +- .../java/org/scijava/command/Commands.java | 2 +- .../java/org/scijava/command/MenuTree.java | 207 ++++++++++++++++++ .../org/scijava/command/CommandsTest.java | 10 +- .../java/org/scijava/command/DeepCommand.java | 47 ++++ .../org/scijava/command/HiddenCommand.java | 43 ++++ .../org/scijava/command/MenuTreeTest.java | 141 ++++++++++++ .../main/java/org/scijava/context/Access.java | 101 +++++++++ .../java/org/scijava/context/Context.java | 47 ---- 9 files changed, 550 insertions(+), 56 deletions(-) create mode 100644 scijava-command/src/main/java/org/scijava/command/MenuTree.java create mode 100644 scijava-command/src/test/java/org/scijava/command/DeepCommand.java create mode 100644 scijava-command/src/test/java/org/scijava/command/HiddenCommand.java create mode 100644 scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java create mode 100644 scijava-context/src/main/java/org/scijava/context/Access.java diff --git a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java index 5dc15ae04..70d21d51d 100644 --- a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java +++ b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java @@ -32,7 +32,7 @@ import java.util.Map; import java.util.Optional; -import org.scijava.context.Context; +import org.scijava.context.Access; import org.scijava.discovery.Discovery; import org.scijava.execute.Executable; import org.scijava.execute.ExecutableInstance; @@ -61,15 +61,13 @@ public class CommandInfo implements Executable { private final Discovery discovery; private final Map menu; - private final Context context; private Executable delegate; CommandInfo(final Discovery discovery, - final Map menu, final Context context) + final Map menu) { this.discovery = discovery; this.menu = menu; - this.context = context; } // -- Metadata, readable without loading the class -- @@ -159,7 +157,7 @@ private synchronized Executable delegate() { // 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, context.privateLookupIn(type)); + delegate = Executables.of(type, Access.lookupIn(type)); } return delegate; } diff --git a/scijava-command/src/main/java/org/scijava/command/Commands.java b/scijava-command/src/main/java/org/scijava/command/Commands.java index 293771ddb..fe271a2a8 100644 --- a/scijava-command/src/main/java/org/scijava/command/Commands.java +++ b/scijava-command/src/main/java/org/scijava/command/Commands.java @@ -71,7 +71,7 @@ public static List discover(final Context context) { final List commands = new ArrayList<>(); for (final Discovery discovery : context.plugins(Command.class)) { commands.add(new CommandInfo(discovery, menus.getOrDefault( // - discovery.implClassName(), Map.of()), context)); + discovery.implClassName(), Map.of()))); } commands.sort(Comparator.comparingDouble(CommandInfo::weight) // .thenComparing(CommandInfo::label)); diff --git a/scijava-command/src/main/java/org/scijava/command/MenuTree.java b/scijava-command/src/main/java/org/scijava/command/MenuTree.java new file mode 100644 index 000000000..604205299 --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/MenuTree.java @@ -0,0 +1,207 @@ +/* + * #%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.command; + +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. + *

+ * Commands with no menu path, and those marked not visible, are left out: + * they remain perfectly runnable, they simply do not appear. + *

+ * + * @param commands the commands to arrange + * @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 the command this entry runs, if it is a leaf. */ + public Optional command() { + 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/scijava-command/src/test/java/org/scijava/command/CommandsTest.java b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java index 8c7aedc29..ede69e6cb 100644 --- a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java +++ b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java @@ -93,13 +93,17 @@ public void testMenuMetadataWithoutLoadingCommands() { @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() // - .filter(c -> c.menuPath().isPresent()) // .map(CommandInfo::label) // + .filter(ofInterest::contains) // .collect(Collectors.toList()); // Weights: CountBeans 3, SayHello 12, NeverRun 50. - assertEquals(List.of("Count Beans", "Say Hello...", "Never Run"), - labels); + assertEquals(ofInterest, labels); } } diff --git a/scijava-command/src/test/java/org/scijava/command/DeepCommand.java b/scijava-command/src/test/java/org/scijava/command/DeepCommand.java new file mode 100644 index 000000000..2efc35fcd --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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/scijava-command/src/test/java/org/scijava/command/HiddenCommand.java b/scijava-command/src/test/java/org/scijava/command/HiddenCommand.java new file mode 100644 index 000000000..3fb631200 --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java b/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java new file mode 100644 index 000000000..e6f1a5b8f --- /dev/null +++ b/scijava-command/src/test/java/org/scijava/command/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.command; + +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.command().orElseThrow() + .className()); + } + } + + /** 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::className) // + .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.command.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/scijava-context/src/main/java/org/scijava/context/Access.java b/scijava-context/src/main/java/org/scijava/context/Access.java new file mode 100644 index 000000000..24ef38454 --- /dev/null +++ b/scijava-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/scijava-context/src/main/java/org/scijava/context/Context.java b/scijava-context/src/main/java/org/scijava/context/Context.java index 924a451fb..320e25171 100644 --- a/scijava-context/src/main/java/org/scijava/context/Context.java +++ b/scijava-context/src/main/java/org/scijava/context/Context.java @@ -29,7 +29,6 @@ package org.scijava.context; -import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -254,52 +253,6 @@ public List subscribe(final Object target) { return subscriptions; } - /** - * Gets a {@link java.lang.invoke.MethodHandles.Lookup} able to reflect into - * the given class, for a collaborator that cannot obtain one itself. - *

- * This exists so that a plugin author writes one line rather than - * two: - *

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

- * Reflective access is checked against the module performing it, so any - * other SciJava module that must read a plugin's fields - the execution - * layer, reading {@code @Parameter} values - would otherwise need its own - * {@code opens}. Handing it a lookup created here means the single - * {@code opens} to this module covers them all. - *

- *

- * NB: the returned lookup is scoped to {@code type} alone, not a - * general-purpose capability: its holder can reflect into that one class - * and nothing else. - *

- *

- * NB: {@code privateLookupIn} also requires this module to read the - * target's module, which a container never declares - plugins depend on it, - * not the reverse. A module may only add that edge from its own code, which - * is the other reason this method has to live here. - *

- * - * @param type the class to be reflected into - * @return a lookup with private access to that class - * @throws ServiceException if the class's package is not open to this module - */ - public MethodHandles.Lookup privateLookupIn(final Class type) { - try { - Context.class.getModule().addReads(type.getModule()); - return MethodHandles.privateLookupIn(type, MethodHandles.lookup()); - } - catch (final IllegalAccessException exc) { - throw new ServiceException("Cannot reflect into " + type.getName() + // - ". Does its module declare `opens " + type.getPackageName() + // - " to org.scijava.context;`?", exc); - } - } - /** Gets this context's event bus. */ public EventBus events() { return events; From b6ff554699e08a5f2bac94e942640fa390e4b918 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 14:42:23 -0500 Subject: [PATCH 33/59] scijava-harvest: prototype the parameter dialog model 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, each of which taught something. A chosen implementation brings its own parameters, and expansion must be driven by the value's class rather than 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 and its static childStruct() are not enough. Static nesting is then the degenerate case where the value's class never varies, which is what makes one concept cover both. 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 -- exactly what a script header needs -- so both are now served by Parameters.builder(), members over a map. Two requirements that looked unrelated turned out to be one primitive, and the fake-script test written earlier as a throwaway became the shipped mechanism. Callbacks now work for scripts, which SciJava Common never managed: it resolved callback names by Java reflection, so a script had nothing to name. ExecutableInstance.behavior(String) resolves a name however that kind of code does. Cascading callbacks terminate: a callback may change anything, and two parameters that set each other -- Celsius and Fahrenheit -- would otherwise never stop, so each callback runs at most once per set. Validation has one protocol: return a message, or do not. Allowing a validator to either throw or return a string is why SciJava Common needs both validate and validateMessage. Two bugs found on the way: only one of the two executable paths resolved behaviors, so visibleWhen and generators silently did nothing; and the no-container reflection path needed addReads for the same reason the container's does, which fully open modules disguised until now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 45 +++ pom.xml | 1 + .../java/org/scijava/execute/Behavior.java | 59 +++ .../scijava/execute/BehaviorException.java | 44 +++ .../scijava/execute/ExecutableInstance.java | 19 + .../java/org/scijava/execute/Executables.java | 6 + .../scijava/execute/FieldParameterMember.java | 46 ++- .../org/scijava/execute/JavaExecutable.java | 76 ++++ .../java/org/scijava/execute/Parameter.java | 21 ++ .../org/scijava/execute/ParameterMember.java | 108 ++++++ .../java/org/scijava/execute/Parameters.java | 190 ++++++++++ scijava-harvest/pom.xml | 143 +++++++ .../src/main/java/module-info.java | 37 ++ .../main/java/org/scijava/harvest/Group.java | 79 ++++ .../main/java/org/scijava/harvest/Groups.java | 43 +++ .../org/scijava/harvest/ObservableStruct.java | 184 +++++++++ .../org/scijava/harvest/ParameterModel.java | 207 ++++++++++ .../org/scijava/harvest/ParameterNode.java | 68 ++++ .../org/scijava/harvest/ParameterTree.java | 354 ++++++++++++++++++ .../scijava/harvest/ParameterModelTest.java | 296 +++++++++++++++ .../scijava/harvest/ParameterTreeTest.java | 269 +++++++++++++ 21 files changed, 2288 insertions(+), 7 deletions(-) create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Behavior.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/BehaviorException.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java create mode 100644 scijava-execute/src/main/java/org/scijava/execute/Parameters.java create mode 100644 scijava-harvest/pom.xml create mode 100644 scijava-harvest/src/main/java/module-info.java create mode 100644 scijava-harvest/src/main/java/org/scijava/harvest/Group.java create mode 100644 scijava-harvest/src/main/java/org/scijava/harvest/Groups.java create mode 100644 scijava-harvest/src/main/java/org/scijava/harvest/ObservableStruct.java create mode 100644 scijava-harvest/src/main/java/org/scijava/harvest/ParameterModel.java create mode 100644 scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java create mode 100644 scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java create mode 100644 scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java create mode 100644 scijava-harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 91c2464b8..56742f1f7 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -760,6 +760,51 @@ after 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. + - **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; and nothing here has met a toolkit yet, which is + where the prototype's `WidgetFactory`/`WidgetPanelFactory` return. + - **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 diff --git a/pom.xml b/pom.xml index 796954d68..4294316d3 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,7 @@ scijava-events scijava-execute scijava-function + scijava-harvest scijava-index scijava-io3 scijava-legacy diff --git a/scijava-execute/src/main/java/org/scijava/execute/Behavior.java b/scijava-execute/src/main/java/org/scijava/execute/Behavior.java new file mode 100644 index 000000000..64eb7f25e --- /dev/null +++ b/scijava-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/scijava-execute/src/main/java/org/scijava/execute/BehaviorException.java b/scijava-execute/src/main/java/org/scijava/execute/BehaviorException.java new file mode 100644 index 000000000..c3d300f83 --- /dev/null +++ b/scijava-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/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java b/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java index ba6ad0727..dad88e97d 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java +++ b/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java @@ -29,6 +29,8 @@ package org.scijava.execute; +import java.util.Optional; + import org.scijava.struct.StructInstance; /** @@ -50,4 +52,21 @@ public interface ExecutableInstance { /** 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/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java index 846717c22..43d70bee0 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Executables.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -126,6 +126,12 @@ public StructInstance parameters() { public void run() { object.run(); } + + @Override + public java.util.Optional behavior(final String name) { + return JavaExecutable.behaviorOf(object.getClass(), null, object, + name); + } }; } }; diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java index a7a96ca14..5920708f8 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -37,7 +37,9 @@ import org.scijava.common3.Types; import org.scijava.struct.ItemIO; -import org.scijava.struct.Member; +import java.util.LinkedHashMap; +import java.util.Map; + import org.scijava.struct.MemberInstance; import org.scijava.struct.ValueAccessible; import org.scijava.struct.ValueAccessibleMemberInstance; @@ -47,7 +49,9 @@ * * @author Curtis Rueden */ -public class FieldParameterMember implements Member, ValueAccessible { +public class FieldParameterMember implements ParameterMember, + ValueAccessible +{ private final Field field; private final Parameter parameter; @@ -119,9 +123,21 @@ public MemberInstance createInstance(final Object o) { return new ValueAccessibleMemberInstance<>(this, o); } - /** Gets the label to display, falling back to the field name. */ - public String label() { - return parameter.label().isEmpty() ? key() : parameter.label(); + @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()); + return attrs; + } + + private static void put(final Map attrs, final String key, + final String value) + { + if (!value.isEmpty()) attrs.put(key, value); } @Override @@ -140,8 +156,7 @@ private synchronized VarHandle handle() { if (handle == null) { try { final Lookup access = lookup != null ? lookup // - : MethodHandles.privateLookupIn(field.getDeclaringClass(), - MethodHandles.lookup()); + : ownLookupIn(field.getDeclaringClass()); handle = access.unreflectVarHandle(field); } catch (final RuntimeException | IllegalAccessException exc) { @@ -151,6 +166,23 @@ private synchronized VarHandle handle() { 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 diff --git a/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java b/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java index deea3fc4e..5758e837d 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java +++ b/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java @@ -29,7 +29,11 @@ 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; @@ -44,6 +48,7 @@ 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); @@ -59,6 +64,7 @@ public JavaExecutable(final Class type, final Lookup lookup) { this.type = type; + this.lookup = lookup; this.struct = Executables.struct(type, lookup); } @@ -92,6 +98,12 @@ public StructInstance parameters() { public void run() { object.run(); } + + @Override + public Optional behavior(final String name) { + return methodNamed(name).map(method -> args -> invoke(method, object, + args)); + } }; } @@ -100,6 +112,70 @@ 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(); diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java index 836b3c07b..c401fc4b0 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java @@ -79,4 +79,25 @@ /** 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 ""; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java new file mode 100644 index 000000000..b11990102 --- /dev/null +++ b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java @@ -0,0 +1,108 @@ +/* + * #%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"; + + /** 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/scijava-execute/src/main/java/org/scijava/execute/Parameters.java b/scijava-execute/src/main/java/org/scijava/execute/Parameters.java new file mode 100644 index 000000000..00eb8bd4a --- /dev/null +++ b/scijava-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/scijava-harvest/pom.xml b/scijava-harvest/pom.xml new file mode 100644 index 000000000..1e11662e1 --- /dev/null +++ b/scijava-harvest/pom.xml @@ -0,0 +1,143 @@ + + + 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.0-SNAPSHOT + 1.0.1-SNAPSHOT + + + + + 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/scijava-harvest/src/main/java/module-info.java b/scijava-harvest/src/main/java/module-info.java new file mode 100644 index 000000000..f85ec82cc --- /dev/null +++ b/scijava-harvest/src/main/java/module-info.java @@ -0,0 +1,37 @@ +/* + * #%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; + +} diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/Group.java b/scijava-harvest/src/main/java/org/scijava/harvest/Group.java new file mode 100644 index 000000000..c683dd67d --- /dev/null +++ b/scijava-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/scijava-harvest/src/main/java/org/scijava/harvest/Groups.java b/scijava-harvest/src/main/java/org/scijava/harvest/Groups.java new file mode 100644 index 000000000..2659319a2 --- /dev/null +++ b/scijava-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/scijava-harvest/src/main/java/org/scijava/harvest/ObservableStruct.java b/scijava-harvest/src/main/java/org/scijava/harvest/ObservableStruct.java new file mode 100644 index 000000000..3a2ac3863 --- /dev/null +++ b/scijava-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/scijava-harvest/src/main/java/org/scijava/harvest/ParameterModel.java b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterModel.java new file mode 100644 index 000000000..11d9276e1 --- /dev/null +++ b/scijava-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/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java new file mode 100644 index 000000000..fc2c71eb3 --- /dev/null +++ b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java @@ -0,0 +1,68 @@ +/* + * #%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; + } +} diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java new file mode 100644 index 000000000..47015a640 --- /dev/null +++ b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java @@ -0,0 +1,354 @@ +/* + * #%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); + for (final MemberInstance child : subMembers(member)) + node.children.add(nodeFor(child)); + return node; + } + + /** + * 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) 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; + + 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 String toString() { + return isGroup() ? "[" + label + "]" : label; + } + } +} diff --git a/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java b/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java new file mode 100644 index 000000000..460894448 --- /dev/null +++ b/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java @@ -0,0 +1,296 @@ +/* + * #%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()); + } + + private static ParameterModel model(final Runnable object) { + final Executable executable = Executables.executableOf(object); + return new ParameterModel(executable.create()); + } +} diff --git a/scijava-harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java b/scijava-harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java new file mode 100644 index 000000000..4841051e0 --- /dev/null +++ b/scijava-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()); + } +} From df48f0158679cbe659e436c229362f9be335f6dc Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 14:59:12 -0500 Subject: [PATCH 34/59] Support computed parameter choices, closing the DynamicCommand gap Counting DynamicCommand's 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 exactly what makes harvesting hard -- a platform cannot know a command's parameters without constructing it. Adding parameters was already covered by generated groups, and better, because those are declared: @Group(membersFrom = ...) says where the dynamic part is and implies that the rest is fixed. This adds the other half. @Parameter(choicesFrom = "behavior") names a behavior producing the values a parameter may take, so the parameter stays statically visible while its values follow the state; choices = {...} covers the static case; and ParameterNode.choices() hands a user interface the resolved list without it caring which kind it was. Being a behavior, it works for scripts, which DynamicCommand never did. There is therefore no DynamicCommand base class and no mutable description. If min and max want the same treatment, minFrom and maxFrom follow the identical pattern -- worth adding when a widget needs it. NB: one full-reactor run failed with twelve ops-image suites erroring in SCIFIO's format discovery (Context.inject on a null context), in code this branch does not touch. It did not reproduce on re-run or in isolation, and the same suites passed in the surrounding runs. Recorded here in case it recurs in CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 19 +++++++ .../scijava/execute/FieldParameterMember.java | 2 + .../java/org/scijava/execute/Parameter.java | 22 ++++++++ .../org/scijava/execute/ParameterMember.java | 6 +++ .../org/scijava/harvest/ParameterNode.java | 11 ++++ .../org/scijava/harvest/ParameterTree.java | 53 ++++++++++++++++++- .../scijava/harvest/ParameterModelTest.java | 50 +++++++++++++++++ 7 files changed, 162 insertions(+), 1 deletion(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 56742f1f7..865003ba5 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -798,6 +798,25 @@ after it. 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 diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java index 5920708f8..3949a215b 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -131,6 +131,8 @@ public Map attrs() { 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()); return attrs; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java index c401fc4b0..913ffcf37 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java @@ -100,4 +100,26 @@ /** 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 ""; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java index b11990102..35ad61198 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java @@ -66,6 +66,12 @@ public interface ParameterMember extends Member { /** 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"; + /** Gets this parameter's metadata. */ Map attrs(); diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java index fc2c71eb3..231c350e0 100644 --- a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java +++ b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java @@ -65,4 +65,15 @@ default boolean isCollapsible() { 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/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java index 47015a640..d81c0cbfc 100644 --- a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java +++ b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java @@ -187,11 +187,49 @@ ParameterTree build() { /** 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()); + } + return attr(member, ParameterMember.CHOICES) // + .map(csv -> (List) new ArrayList(List.of(csv.split( + ",")))) // + .orElse(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. *

@@ -206,7 +244,14 @@ private List> subMembers( final MemberInstance member) { final Object value = member.isReadable() ? member.get() : null; - if (value == null) return List.of(); + 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(); @@ -315,6 +360,7 @@ private static class DefaultNode implements ParameterNode { 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; @@ -346,6 +392,11 @@ public boolean isCollapsed() { return collapsed; } + @Override + public List choices() { + return choices; + } + @Override public String toString() { return isGroup() ? "[" + label + "]" : label; diff --git a/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java b/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java index 460894448..34f8f5a41 100644 --- a/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java +++ b/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java @@ -289,6 +289,56 @@ public Optional behavior(final String 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()); From 58fd161e200800d39080d5940b724850fda8097c Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 15:24:58 -0500 Subject: [PATCH 35/59] scijava-ui3 and scijava-swing: widgets, and dialogs to click on The contracts first, toolkit-free: - `Widget` is a view of a `ParameterNode`, and that is all. 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, bounds, style and resolved choices, and writes through `ParameterModel`. Writing through the model is what runs callbacks and reshapes the dialog, so a widget earns the dynamic behavior by doing nothing clever. - `WidgetPanel` *is* a `Widget`, so a group nests in its parent's list with no special case, however deep. `WidgetPanels` walks the tree once for every toolkit; a binding supplies factories and nothing else. - `InputHarvester` is a `Preprocessor`. A dismissed dialog calls `Execution.decline`, an ordinary outcome the `ExecutionResult` reports, rather than a `canceled` flag on a stateful preprocessor. Then Swing: number (spinner, optional slider), text (field, password, area), toggle, choice, file, the panel and the dialog. MigLayout is gone in favor of `GridBagLayout` -- two columns of labels and fields are not worth a third-party dependency, still less an automatic module in a JPMS build. The button, color, date, file-list, message and radio-choice widgets are not ported; each earns its place when something asks. Three changes fell out of meeting a toolkit: - `@Parameter` gains `min`, `max`, `stepSize` and `style`. All strings, because metadata has to survive a script header; `Widgets` reads the bounds back in the parameter's own type, and style hints are advisory, so a parameter styled for Swing still renders elsewhere. - `Execution.executable()` returns `ExecutableInstance` rather than `Object`. Its one caller already cast it. - `Executables.executableOf` takes a lookup, so an object run through a container needs no `opens` beyond the one its module already declares. `Demos` in the test sources puts each tricky behavior on screen, with a launcher window and a `main` that takes a demo's name: mvn -o -pl scijava-swing test-compile exec:java \ -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.swing.Demos The kitchen sink, an advanced toggle, a generated group, computed choices, live validation, and two values that keep each other in step. `SwingWidgetsTest` covers the same six headlessly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 45 ++- docs/migration.md | 5 +- pom.xml | 2 + .../java/org/scijava/execute/Executables.java | 22 +- .../java/org/scijava/execute/Execution.java | 4 +- .../scijava/execute/FieldParameterMember.java | 4 + .../java/org/scijava/execute/Parameter.java | 21 + .../org/scijava/execute/ParameterMember.java | 12 + .../main/java/org/scijava/execute/Runner.java | 2 +- .../execute/ScriptLikeExecutableTest.java | 2 +- scijava-swing/pom.xml | 203 ++++++++++ scijava-swing/src/main/java/module-info.java | 44 ++ .../org/scijava/swing/SwingChoiceWidget.java | 116 ++++++ .../swing/SwingChoiceWidgetFactory.java | 64 +++ .../java/org/scijava/swing/SwingDialog.java | 176 ++++++++ .../org/scijava/swing/SwingFileWidget.java | 126 ++++++ .../scijava/swing/SwingFileWidgetFactory.java | 60 +++ .../scijava/swing/SwingInputHarvester.java | 133 ++++++ .../org/scijava/swing/SwingNumberWidget.java | 176 ++++++++ .../swing/SwingNumberWidgetFactory.java | 65 +++ .../java/org/scijava/swing/SwingPanel.java | 196 +++++++++ .../org/scijava/swing/SwingPanelFactory.java | 57 +++ .../org/scijava/swing/SwingTextWidget.java | 120 ++++++ .../scijava/swing/SwingTextWidgetFactory.java | 65 +++ .../org/scijava/swing/SwingToggleWidget.java | 68 ++++ .../swing/SwingToggleWidgetFactory.java | 58 +++ .../java/org/scijava/swing/SwingWidget.java | 126 ++++++ .../org/scijava/swing/SwingWidgetFactory.java | 58 +++ .../test/java/org/scijava/swing/Demos.java | 380 ++++++++++++++++++ .../org/scijava/swing/SwingWidgetsTest.java | 182 +++++++++ scijava-ui3/pom.xml | 161 ++++++++ scijava-ui3/src/main/java/module-info.java | 39 ++ .../java/org/scijava/ui3/InputHarvester.java | 79 ++++ .../src/main/java/org/scijava/ui3/Widget.java | 56 +++ .../java/org/scijava/ui3/WidgetFactory.java | 79 ++++ .../java/org/scijava/ui3/WidgetPanel.java | 54 +++ .../org/scijava/ui3/WidgetPanelFactory.java | 59 +++ .../java/org/scijava/ui3/WidgetPanels.java | 104 +++++ .../main/java/org/scijava/ui3/Widgets.java | 182 +++++++++ 39 files changed, 3391 insertions(+), 14 deletions(-) create mode 100644 scijava-swing/pom.xml create mode 100644 scijava-swing/src/main/java/module-info.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingFileWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingPanel.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingPanelFactory.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java create mode 100644 scijava-swing/src/test/java/org/scijava/swing/Demos.java create mode 100644 scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java create mode 100644 scijava-ui3/pom.xml create mode 100644 scijava-ui3/src/main/java/module-info.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/InputHarvester.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/Widget.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/WidgetFactory.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 865003ba5..c35b8d27b 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -689,7 +689,7 @@ 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** (to build) | `scijava-command` (a struct plus presentation metadata), `scijava-menu` (the menu tree), `scijava-ui-api` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | +| **1. Application model** (to build) | `scijava-command` (a struct plus presentation metadata), `scijava-menu` (the menu tree), `scijava-ui3` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | | **2. Toolkit bindings** | `scijava-ui-swing`, JavaFX, headless | layer 1 | | **3. Application** | Fiji: which menus exist, branding, update sites, defaults | layer 2 | @@ -821,8 +821,43 @@ after it. 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; and nothing here has met a toolkit yet, which is - where the prototype's `WidgetFactory`/`WidgetPanelFactory` return. + 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. + - **Six widgets so far** — number (spinner, with an optional slider), text + (field, password, area), toggle, choice, file, and the panel itself. The + button, color, date, file-list, message and radio-choice widgets are not + ported; each earns its place when something asks for it. + - **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. @@ -834,7 +869,7 @@ after it. `org.scijava.widget` as written. - **The application shell moves to layers 1 and 2**, it is not discarded: `ApplicationFrame`, `Desktop`, `StatusBar` and `ToolBar` are contracts in - `scijava-ui-api` and implementations in the toolkit bindings. What the core + `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 @@ -868,7 +903,7 @@ after it. | `module`, `command` | → execution layer (Phase 3) | | `script` | → scripting facade + adapters (Phase 3) | | `widget` | → input harvesting, redesigned (Phase 4) | -| `ui`, `menu` | `scijava-ui-api` contracts and `scijava-menu` (layer 1), with toolkit bindings in layer 2 (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) | diff --git a/docs/migration.md b/docs/migration.md index 344a29f27..5a6e7fb5f 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -124,8 +124,9 @@ A **dropped** row is a successful outcome, not a gap. | `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` | still to come: a command is a struct plus menu metadata plus `run` | planned | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | -| `widget` | input harvesting on `scijava-struct`, with dynamic subgroups (Phase 4) | planned | -| `ui`, `menu` | `scijava-ui-api` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | +| `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.swing.widget` | `org.scijava.swing`: number (spinner, optional slider), text (field, password, area), toggle, choice, file, and the panel and dialog. MigLayout is gone, in favor of `GridBagLayout`: one fewer dependency, and one fewer automatic module. The button, color, date, file-list, message and radio-choice widgets are not ported yet - each earns its place when something asks for it | ported | +| `ui`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/pom.xml b/pom.xml index 4294316d3..95c4ea07b 100644 --- a/pom.xml +++ b/pom.xml @@ -85,8 +85,10 @@ scijava-progress scijava-spi scijava-struct + scijava-swing scijava-testutil scijava-types + scijava-ui3 diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java index 43d70bee0..3f65e4648 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Executables.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -94,7 +94,23 @@ public static Executable of(final Class type, *

*/ public static Executable executableOf(final Runnable object) { - final Struct struct = struct(object.getClass()); + 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 @@ -114,7 +130,7 @@ public ExecutableInstance create() { @Override public Executable executable() { - return Executables.executableOf(object); + return Executables.executableOf(object, lookup); } @Override @@ -129,7 +145,7 @@ public void run() { @Override public java.util.Optional behavior(final String name) { - return JavaExecutable.behaviorOf(object.getClass(), null, object, + return JavaExecutable.behaviorOf(object.getClass(), lookup, object, name); } }; diff --git a/scijava-execute/src/main/java/org/scijava/execute/Execution.java b/scijava-execute/src/main/java/org/scijava/execute/Execution.java index f8274984a..d410cd6ff 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Execution.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Execution.java @@ -42,8 +42,8 @@ */ public interface Execution { - /** Gets the object being run. */ - Object executable(); + /** Gets the thing being run, whose parameters and behaviors it carries. */ + ExecutableInstance executable(); /** Gets its parameters, bound to their values. */ StructInstance instance(); diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java index 3949a215b..ce0f854d0 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -133,6 +133,10 @@ public Map attrs() { 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, STYLE, parameter.style()); return attrs; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java index 913ffcf37..65a892161 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java @@ -122,4 +122,25 @@ *

*/ 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 ""; + + /** + * 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/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java index 35ad61198..35be2aade 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java @@ -72,6 +72,18 @@ public interface ParameterMember extends Member { /** 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: a hint about how to display this parameter. */ + String STYLE = "style"; + /** Gets this parameter's metadata. */ Map attrs(); diff --git a/scijava-execute/src/main/java/org/scijava/execute/Runner.java b/scijava-execute/src/main/java/org/scijava/execute/Runner.java index ac25036d1..3d7d63962 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Runner.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Runner.java @@ -215,7 +215,7 @@ void complete(final Map outputs) { } @Override - public Object executable() { + public ExecutableInstance executable() { return instance; } diff --git a/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java b/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java index be0cf6e2a..72cd05c13 100644 --- a/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java +++ b/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java @@ -182,7 +182,7 @@ public void testAScriptShapedExecutableRuns() throws Exception { public void testProcessorsApplyToScriptsToo() throws Exception { final List seen = new ArrayList<>(); final Preprocessor filler = execution -> { - seen.add(((ExecutableInstance) execution.executable()).executable() + seen.add(execution.executable().executable() .name()); execution.instance().member("name").set("grace"); }; diff --git a/scijava-swing/pom.xml b/scijava-swing/pom.xml new file mode 100644 index 000000000..7324c99b8 --- /dev/null +++ b/scijava-swing/pom.xml @@ -0,0 +1,203 @@ + + + 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. + + + 1.0.0-SNAPSHOT + 1.0.1-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-common3 + ${scijava-common3.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.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/scijava-swing/src/main/java/module-info.java b/scijava-swing/src/main/java/module-info.java new file mode 100644 index 000000000..4888a2bb4 --- /dev/null +++ b/scijava-swing/src/main/java/module-info.java @@ -0,0 +1,44 @@ +/* + * #%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.context; + requires org.scijava.discovery; + requires org.scijava.priority; + +} diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java new file mode 100644 index 000000000..1a5c0d228 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java new file mode 100644 index 000000000..bb301a992 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java b/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java new file mode 100644 index 000000000..95ed2cf82 --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java @@ -0,0 +1,176 @@ +/* + * #%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.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 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; + + 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(this::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); + content.removeAll(); + content.add(new JScrollPane(panel.component()), BorderLayout.CENTER); + validate(); + dialog.pack(); + final Dimension size = dialog.getSize(); + // 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 && size.height > screen.height * 0.8) { + dialog.setSize(size.width + 20, (int) (screen.height * 0.8)); + } + dialog.setLocationRelativeTo(dialog.getOwner()); + } + 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/scijava-swing/src/main/java/org/scijava/swing/SwingFileWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingFileWidget.java new file mode 100644 index 000000000..4a3668857 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java new file mode 100644 index 000000000..5b13dee85 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java b/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java new file mode 100644 index 000000000..32c1e6bbb --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java @@ -0,0 +1,133 @@ +/* + * #%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; + +/** + * 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 { + + @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); + } + } + factories = List.copyOf(found); + return factories; + } +} diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java new file mode 100644 index 000000000..a373518a1 --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java @@ -0,0 +1,176 @@ +/* + * #%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 javax.swing.JComponent; +import javax.swing.JPanel; +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 with a slider beside it. + * + * @author Curtis Rueden + */ +public class SwingNumberWidget extends SwingWidget { + + /** Style hint: put a slider beside the spinner. */ + public static final String SLIDER = "slider"; + + private final Class type; + private final Number min; + private final Number max; + private final Number step; + + private final JSpinner spinner; + private final JSlider slider; + private final JPanel component; + + 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); + 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)); + slider = Widgets.isStyle(node, SLIDER) && min != null && max != null // + ? slider() : null; + if (slider != null) component.add(slider, BorderLayout.CENTER); + component.add(spinner, slider == null ? BorderLayout.CENTER + : BorderLayout.EAST); + + refresh(); + spinner.addChangeListener(e -> { + syncSlider(); + update(number(spinner.getValue())); + }); + if (slider != null) { + slider.addChangeListener(e -> { + if (slider.getValueIsAdjusting() || suppressSlider) return; + spinner.setValue(number(fromSlider(slider.getValue()))); + }); + } + } + + @Override + public JComponent component() { + return component; + } + + @Override + protected void doRefresh() { + final Number value = number(value()); + if (!value.equals(spinner.getValue())) spinner.setValue(value); + syncSlider(); + } + + // -- Helper methods -- + + private boolean suppressSlider; + + 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); + s.setPreferredSize(new Dimension(160, s.getPreferredSize().height)); + return s; + } + + /** Gets how many steps the slider spans, capped at a usable number. */ + private int steps() { + final double span = max.doubleValue() - min.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 syncSlider() { + if (slider == null) return; + suppressSlider = true; + try { + final double value = number(spinner.getValue()).doubleValue(); + final double span = max.doubleValue() - min.doubleValue(); + final int pos = (int) Math.round((value - min.doubleValue()) / span * + steps()); + slider.setValue(Math.max(0, Math.min(steps(), pos))); + } + finally { + suppressSlider = false; + } + } + + private double fromSlider(final int pos) { + final double span = max.doubleValue() - min.doubleValue(); + return min.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/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java new file mode 100644 index 000000000..c21a45f52 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingPanel.java b/scijava-swing/src/main/java/org/scijava/swing/SwingPanel.java new file mode 100644 index 000000000..f211e6924 --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingPanel.java @@ -0,0 +1,196 @@ +/* + * #%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. + } + + /** + * Marks the parameters the model reports problems with, and clears the + * marks on the rest. + * + * @param problems what is wrong, by parameter name + */ + 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/scijava-swing/src/main/java/org/scijava/swing/SwingPanelFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingPanelFactory.java new file mode 100644 index 000000000..ba87f21ba --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java new file mode 100644 index 000000000..f6ff6f27a --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java @@ -0,0 +1,120 @@ +/* + * #%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(); + if (Widgets.type(node()) == String.class) update(s); + else if (!s.isEmpty()) update(s.charAt(0)); // NB: a char parameter + } +} diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java new file mode 100644 index 000000000..799454112 --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.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 SwingTextWidget}s. + * + * @author Curtis Rueden + */ +@Plugin(type = WidgetFactory.class) +public class SwingTextWidgetFactory implements SwingWidgetFactory { + + @Override + public boolean supports(final ParameterNode node) { + return node.choices().isEmpty() && Widgets.isText(node); + } + + @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/scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidget.java new file mode 100644 index 000000000..47c70bfd1 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java new file mode 100644 index 000000000..f5d4e4219 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java new file mode 100644 index 000000000..02a699579 --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.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 javax.swing.JComponent; +import javax.swing.JPanel; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.Widget; + +/** + * A {@link Widget} made of Swing components. + * + * @author Curtis Rueden + */ +public abstract class SwingWidget implements Widget { + + private final ParameterNode node; + private final ParameterModel model; + + private boolean refreshing; + + protected SwingWidget(final ParameterNode node, final ParameterModel model) { + this.node = node; + this.model = model; + } + + /** Gets the component to place in the dialog. */ + public abstract JComponent component(); + + /** + * Gets whether the dialog should put a label beside this widget. A widget + * that says no gets the whole row. + */ + public boolean isLabeled() { + return true; + } + + @Override + public ParameterNode node() { + return 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 Swing component 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 components. */ + protected abstract void doRefresh(); + + /** Gets the parameter's name. */ + protected String key() { + return node.member().orElseThrow().member().key(); + } + + /** Gets the parameter's current value. */ + protected Object value() { + return node.member().orElseThrow().get(); + } + + /** + * 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); + } + + /** 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/scijava-swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java new file mode 100644 index 000000000..ddcc4a13e --- /dev/null +++ b/scijava-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/scijava-swing/src/test/java/org/scijava/swing/Demos.java b/scijava-swing/src/test/java/org/scijava/swing/Demos.java new file mode 100644 index 000000000..99daa7dbe --- /dev/null +++ b/scijava-swing/src/test/java/org/scijava/swing/Demos.java @@ -0,0 +1,380 @@ +/* + * #%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.io.File; +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.Parameter; +import org.scijava.execute.Parameters; +import org.scijava.execute.Runner; +import org.scijava.harvest.Group; +import org.scijava.struct.ItemIO; +import org.scijava.struct.StructInstance; + +/** + * 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. + *

+ * + * @author Curtis Rueden + */ +public final class Demos { + + private Demos() { + // 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(description = "Shown as the tooltip of this parameter's label") + private String name = "ada"; + + @Parameter(min = "0", max = "100", style = "slider") + private int percent = 50; + + @Parameter(min = "0", max = "1", stepSize = "0.01") + private double weight = 0.25; + + @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 + "% w=" + weight + " " + statistic + + (enabled ? " [enabled]" : "") + " in=" + input + " out=" + outputDir + + " notes=" + notes.length() + " chars"; + } + } + + // -- 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() {} + } + + // -- Launcher -- + + private static final Map> DEMOS = + new LinkedHashMap<>(); + + static { + DEMOS.put("Kitchen sink", KitchenSink.class); + DEMOS.put("Advanced toggle", AdvancedToggle.class); + DEMOS.put("Generated group", GeneratedGroup.class); + DEMOS.put("Computed choices", ComputedChoices.class); + DEMOS.put("Live validation", LiveValidation.class); + DEMOS.put("Linked values", LinkedValues.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/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java b/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java new file mode 100644 index 000000000..f8a521e89 --- /dev/null +++ b/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java @@ -0,0 +1,182 @@ +/* + * #%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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.GraphicsEnvironment; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIf; +import org.scijava.context.Access; +import org.scijava.execute.Executables; +import org.scijava.harvest.ParameterModel; +import org.scijava.ui3.WidgetFactory; +import org.scijava.ui3.WidgetPanels; + +/** + * Tests that the widgets a dialog builds match the parameters it was given, + * and that they follow the parameters as values change. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class SwingWidgetsTest { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + private static final List> FACTORIES = List.of( // + new SwingChoiceWidgetFactory(), // + new SwingNumberWidgetFactory(), // + new SwingToggleWidgetFactory(), // + new SwingFileWidgetFactory(), // + new SwingTextWidgetFactory()); + + @Test + public void testWidgetPerParameterType() { + final ParameterModel model = model(new Demos.KitchenSink()); + final SwingPanel panel = panel(model); + + assertEquals(List.of( // + SwingTextWidget.class, // name + SwingNumberWidget.class, // percent + SwingNumberWidget.class, // weight + SwingToggleWidget.class, // enabled + SwingChoiceWidget.class, // statistic + SwingFileWidget.class, // input + SwingFileWidget.class, // outputDir + SwingTextWidget.class, // secret + SwingTextWidget.class), // notes + types(panel)); + } + + /** A group appears, with its own widgets, when the value asks for it. */ + @Test + public void testGroupAppearsWhenToggled() { + final ParameterModel model = model(new Demos.AdvancedToggle()); + assertEquals(2, panel(model).widgets().size()); + + model.set("advanced", true); + + final SwingPanel panel = panel(model); + assertEquals(3, panel.widgets().size()); + final SwingWidget group = panel.widgets().get(2); + final SwingPanel nested = assertInstanceOf(SwingPanel.class, group); + assertFalse(nested.isLabeled()); // NB: a group carries its own title + assertEquals(List.of(SwingNumberWidget.class, SwingToggleWidget.class), + types(nested)); + } + + /** The number of widgets follows a value, not the class's fields. */ + @Test + public void testGeneratedGroupGrows() { + final ParameterModel model = model(new Demos.GeneratedGroup()); + assertEquals(2, nested(panel(model)).widgets().size()); + + model.set("numDims", 4); + + assertEquals(4, nested(panel(model)).widgets().size()); + } + + /** A choice widget offers whatever the other value implies, right now. */ + @Test + public void testComputedChoicesFollowAnotherValue() { + final ParameterModel model = model(new Demos.ComputedChoices()); + assertEquals(List.of("X", "Y", "Z", "Channel"), model.tree().find("column") + .orElseThrow().choices()); + + model.set("file", "table.csv"); + + final SwingPanel panel = panel(model); + final SwingChoiceWidget column = assertInstanceOf(SwingChoiceWidget.class, + panel.widgets().get(1)); + assertEquals(List.of("id", "label", "area", "mean"), column.node() + .choices()); + } + + /** A widget writes through the model, so callbacks run. */ + @Test + public void testWidgetEditRunsCallback() { + final ParameterModel model = model(new Demos.LinkedValues()); + final SwingPanel panel = panel(model); + final SwingNumberWidget celsius = assertInstanceOf(SwingNumberWidget.class, + panel.widgets().get(0)); + + celsius.update(100.0); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** Problems are reported per parameter, and clear when fixed. */ + @Test + public void testValidationMarksTheOffendingParameter() { + final ParameterModel model = model(new Demos.LiveValidation()); + final SwingPanel panel = panel(model); + assertTrue(model.problems().isEmpty()); + + model.set("count", 42); + panel.showProblems(model.problems()); + assertEquals("Count must be between 1 and 10", model.problems().get( + "count")); + + model.set("count", 3); + assertTrue(model.problems().isEmpty()); + } + + // -- Helper methods -- + + private static ParameterModel model(final Runnable object) { + return new ParameterModel(Executables.executableOf(object, Access.lookupIn( + object.getClass())).create()); + } + + private static SwingPanel panel(final ParameterModel model) { + return (SwingPanel) new WidgetPanels<>(FACTORIES, new SwingPanelFactory( + model)).build(model); + } + + private static SwingPanel nested(final SwingPanel panel) { + return panel.widgets().stream() // + .filter(SwingPanel.class::isInstance).map(SwingPanel.class::cast) // + .findFirst().orElseThrow(); + } + + private static List> types(final SwingPanel panel) { + return panel.widgets().stream().map(Object::getClass) // + .collect(Collectors.toList()); + } +} diff --git a/scijava-ui3/pom.xml b/scijava-ui3/pom.xml new file mode 100644 index 000000000..04f3b6b6a --- /dev/null +++ b/scijava-ui3/pom.xml @@ -0,0 +1,161 @@ + + + 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.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/scijava-ui3/src/main/java/module-info.java b/scijava-ui3/src/main/java/module-info.java new file mode 100644 index 000000000..749342f82 --- /dev/null +++ b/scijava-ui3/src/main/java/module-info.java @@ -0,0 +1,39 @@ +/* + * #%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; + +} diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/InputHarvester.java b/scijava-ui3/src/main/java/org/scijava/ui3/InputHarvester.java new file mode 100644 index 000000000..418596b8a --- /dev/null +++ b/scijava-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/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java b/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java new file mode 100644 index 000000000..c0aade9d1 --- /dev/null +++ b/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java @@ -0,0 +1,56 @@ +/* + * #%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(); +} diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetFactory.java b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetFactory.java new file mode 100644 index 000000000..ea8f3fa4d --- /dev/null +++ b/scijava-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/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java new file mode 100644 index 000000000..201adf468 --- /dev/null +++ b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java @@ -0,0 +1,54 @@ +/* + * #%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; + +/** + * 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); + } +} diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java new file mode 100644 index 000000000..039ce84bb --- /dev/null +++ b/scijava-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/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java new file mode 100644 index 000000000..20b26824c --- /dev/null +++ b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java @@ -0,0 +1,104 @@ +/* + * #%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; + +/** + * 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 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. + 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/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java b/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java new file mode 100644 index 000000000..166d4f2d8 --- /dev/null +++ b/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java @@ -0,0 +1,182 @@ +/* + * #%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.math.BigDecimal; +import java.math.BigInteger; +import java.util.Optional; + +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 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 number in the given type. + *

+ * NB: bounds arrive as strings, because metadata has to survive a script + * header; the widget wants them in the parameter's own type, so that a + * {@code long} parameter is not silently bounded by a {@code double}. + *

+ * + * @return the parsed number, or null if the text is not a number + */ + public static Number toType(final String text, final Class type) { + if (text == null || text.isEmpty()) return null; + try { + final Class t = box(type); + if (t == Byte.class) return Byte.valueOf(text); + if (t == Short.class) return Short.valueOf(text); + if (t == Integer.class) return Integer.valueOf(text); + if (t == Long.class) return Long.valueOf(text); + if (t == Float.class) return Float.valueOf(text); + if (t == BigInteger.class) return new BigInteger(text); + if (t == BigDecimal.class) return new BigDecimal(text); + return Double.valueOf(text); + } + catch (final NumberFormatException exc) { + return 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); + } +} From 0a57e16a441004690ae089e4bb406c5cdc338270 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 17:34:23 -0500 Subject: [PATCH 36/59] Fix the widget bugs the demos turned up, and fill in the gaps The empty dialogs were not a widget bug: with no annotation index on the classpath no widget factory is discovered, and the dialog comes up with nothing but OK and Cancel. javac writes that index only when scijava-index is on the annotation processor path, and says nothing at all when it is not, so the harvester now says it -- naming the file -- and `WidgetPanels` warns about any parameter no factory accepted. Both through SLF4J, with slf4j-simple as a test dependency so the demos log: -Dorg.slf4j.simpleLogger.defaultLogLevel=debug The stale choices were real. A widget held the `ParameterNode` it was built from, but `ParameterModel` builds a new tree on every `set`; where the tree's shape was unchanged nothing rebuilt, so a chooser went on asking a dead node for its values while the model had already moved on. `SwingWidget.node()` now reads its node back out of the current tree. Dialog behavior, from watching one: it never moves and never shrinks after the first build -- it may grow for parameters that have just appeared, but re-centring or closing up around a collapsed group moves the controls out from under the pointer. The scroll pane's border is gone, and the widgets hug the top, a GridBagLayout having otherwise centered its rows in whatever height the dialog was dragged to. Gaps filled, all of them things SciJava Common had: - a scroll bar as well as a slider, and labelled tick marks (`style = "slider,ticks:4"`); - `softMin`/`softMax`, so the slider spans the useful range while the spinner still enforces the permitted one; - enums, whose constants are their choices -- resolved in `ParameterTree`, so every toolkit gets it and no annotation repeats what the enum already says; - `BigInteger` and `BigDecimal`, which go to the text widget and are parsed exactly, a spinner being unable to step them; - `char`, and boxed numbers alongside primitive ones; - message rows (`style = "message"`), whose value a callback may rewrite. And a seventh demo, the one worth having: width, height and a lock aspect ratio checkbox, with a message row showing the live ratio. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 28 ++++- docs/migration.md | 2 +- .../scijava/execute/FieldParameterMember.java | 2 + .../java/org/scijava/execute/Parameter.java | 14 +++ .../org/scijava/execute/ParameterMember.java | 6 + scijava-harvest/pom.xml | 6 + .../src/main/java/module-info.java | 1 + .../org/scijava/harvest/ParameterTree.java | 16 ++- scijava-swing/pom.xml | 15 +++ scijava-swing/src/main/java/module-info.java | 1 + .../java/org/scijava/swing/SwingDialog.java | 74 +++++++++-- .../scijava/swing/SwingInputHarvester.java | 17 +++ .../org/scijava/swing/SwingMessageWidget.java | 77 ++++++++++++ .../swing/SwingMessageWidgetFactory.java | 65 ++++++++++ .../org/scijava/swing/SwingNumberWidget.java | 116 +++++++++++++----- .../org/scijava/swing/SwingTextWidget.java | 16 ++- .../scijava/swing/SwingTextWidgetFactory.java | 7 +- .../java/org/scijava/swing/SwingWidget.java | 16 ++- .../test/java/org/scijava/swing/Demos.java | 93 +++++++++++++- .../org/scijava/swing/SwingWidgetsTest.java | 51 ++++++++ scijava-ui3/pom.xml | 6 + scijava-ui3/src/main/java/module-info.java | 1 + .../java/org/scijava/ui3/WidgetPanels.java | 10 +- .../main/java/org/scijava/ui3/Widgets.java | 18 +++ 24 files changed, 599 insertions(+), 59 deletions(-) create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidget.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index c35b8d27b..54eb9d72a 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -845,10 +845,30 @@ after it. - **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. - - **Six widgets so far** — number (spinner, with an optional slider), text - (field, password, area), toggle, choice, file, and the panel itself. The - button, color, date, file-list, message and radio-choice widgets are not - ported; each earns its place when something asks for it. + - **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 diff --git a/docs/migration.md b/docs/migration.md index 5a6e7fb5f..ca3c53988 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -125,7 +125,7 @@ A **dropped** row is a successful outcome, not a gap. | `command` | still to come: a command is a struct plus menu metadata plus `run` | planned | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `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.swing.widget` | `org.scijava.swing`: number (spinner, optional slider), text (field, password, area), toggle, choice, file, and the panel and dialog. MigLayout is gone, in favor of `GridBagLayout`: one fewer dependency, and one fewer automatic module. The button, color, date, file-list, message and radio-choice widgets are not ported yet - each earns its place when something asks for it | 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 | | `ui`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java index ce0f854d0..96f70e745 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -136,6 +136,8 @@ public Map attrs() { 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()); return attrs; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java index 65a892161..044ae2495 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java @@ -132,6 +132,20 @@ /** 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"}. diff --git a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java index 35be2aade..55e721e9e 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java @@ -81,6 +81,12 @@ public interface ParameterMember extends Member { /** 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: a hint about how to display this parameter. */ String STYLE = "style"; diff --git a/scijava-harvest/pom.xml b/scijava-harvest/pom.xml index 1e11662e1..09dffed5b 100644 --- a/scijava-harvest/pom.xml +++ b/scijava-harvest/pom.xml @@ -86,11 +86,17 @@ 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 diff --git a/scijava-harvest/src/main/java/module-info.java b/scijava-harvest/src/main/java/module-info.java index f85ec82cc..dd2c558d1 100644 --- a/scijava-harvest/src/main/java/module-info.java +++ b/scijava-harvest/src/main/java/module-info.java @@ -33,5 +33,6 @@ requires transitive org.scijava.execute; requires transitive org.scijava.struct; + requires org.scijava.common3; } diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java index d81c0cbfc..5cbda6e49 100644 --- a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java +++ b/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java @@ -208,10 +208,18 @@ private List choices(final MemberInstance member) { final Optional behavior = executable.behavior(from.get()); if (behavior.isPresent()) return asList(behavior.get().invoke()); } - return attr(member, ParameterMember.CHOICES) // - .map(csv -> (List) new ArrayList(List.of(csv.split( - ",")))) // - .orElse(List.of()); + 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) { diff --git a/scijava-swing/pom.xml b/scijava-swing/pom.xml index 7324c99b8..7f89200fd 100644 --- a/scijava-swing/pom.xml +++ b/scijava-swing/pom.xml @@ -144,7 +144,22 @@ ${scijava-struct.version} + + + org.slf4j + slf4j-api + + + + + org.slf4j + slf4j-simple + test + org.junit.jupiter junit-jupiter-api diff --git a/scijava-swing/src/main/java/module-info.java b/scijava-swing/src/main/java/module-info.java index 4888a2bb4..65fea482b 100644 --- a/scijava-swing/src/main/java/module-info.java +++ b/scijava-swing/src/main/java/module-info.java @@ -40,5 +40,6 @@ requires transitive org.scijava.context; requires org.scijava.discovery; requires org.scijava.priority; + requires org.slf4j; } diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java b/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java index 95ed2cf82..f6a58a779 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java @@ -44,6 +44,9 @@ 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; @@ -60,6 +63,8 @@ */ public class SwingDialog { + private static final Logger log = LoggerFactory.getLogger(SwingDialog.class); + private final ParameterModel model; private final WidgetPanels builder; private final JDialog dialog; @@ -70,6 +75,7 @@ public class SwingDialog { private SwingPanel panel; private boolean accepted; private boolean rebuilding; + private boolean placed; public SwingDialog(final Window owner, final String title, final ParameterModel model, @@ -108,7 +114,10 @@ public SwingDialog(final Window owner, final String title, // 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(this::rebuild)); + 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( @@ -140,25 +149,66 @@ private void rebuild() { 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(); - content.add(new JScrollPane(panel.component()), BorderLayout.CENTER); + // 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(); - dialog.pack(); - final Dimension size = dialog.getSize(); - // 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 && size.height > screen.height * 0.8) { - dialog.setSize(size.width + 20, (int) (screen.height * 0.8)); - } - dialog.setLocationRelativeTo(dialog.getOwner()); + 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(); diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java b/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java index 32c1e6bbb..92c415918 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java @@ -44,6 +44,8 @@ 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. @@ -53,6 +55,9 @@ @Plugin(type = Preprocessor.class) public class SwingInputHarvester extends InputHarvester { + private static final Logger log = LoggerFactory.getLogger( + SwingInputHarvester.class); + @Dependency(required = false) private Context context; @@ -127,6 +132,18 @@ private synchronized List> factories() { 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/scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidget.java new file mode 100644 index 000000000..8d73c0b76 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java new file mode 100644 index 000000000..20bee68da --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java index a373518a1..97690c240 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java @@ -31,9 +31,12 @@ 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; @@ -43,7 +46,14 @@ import org.scijava.ui3.Widgets; /** - * A widget for a number: a spinner, optionally with a slider beside it. + * 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 */ @@ -52,15 +62,26 @@ 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) { @@ -68,6 +89,8 @@ public SwingNumberWidget(final ParameterNode node, 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()); @@ -77,23 +100,27 @@ public SwingNumberWidget(final ParameterNode node, component = panel(); component.setLayout(new BorderLayout(4, 0)); - slider = Widgets.isStyle(node, SLIDER) && min != null && max != null // - ? slider() : null; - if (slider != null) component.add(slider, BorderLayout.CENTER); - component.add(spinner, slider == null ? BorderLayout.CENTER + + 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 -> { - syncSlider(); + syncScale(); update(number(spinner.getValue())); }); - if (slider != null) { - slider.addChangeListener(e -> { - if (slider.getValueIsAdjusting() || suppressSlider) return; - spinner.setValue(number(fromSlider(slider.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 @@ -105,56 +132,87 @@ public JComponent component() { protected void doRefresh() { final Number value = number(value()); if (!value.equals(spinner.getValue())) spinner.setValue(value); - syncSlider(); + syncScale(); } // -- Helper methods -- - private boolean suppressSlider; - 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); + 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; } - /** Gets how many steps the slider spans, capped at a usable number. */ + /** 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 = max.doubleValue() - min.doubleValue(); + 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 syncSlider() { - if (slider == null) return; - suppressSlider = true; + private void syncScale() { + if (slider == null && scrollBar == null) return; + syncing = true; try { final double value = number(spinner.getValue()).doubleValue(); - final double span = max.doubleValue() - min.doubleValue(); - final int pos = (int) Math.round((value - min.doubleValue()) / span * + final double span = softMax.doubleValue() - softMin.doubleValue(); + final int pos = (int) Math.round((value - softMin.doubleValue()) / span * steps()); - slider.setValue(Math.max(0, Math.min(steps(), pos))); + final int clamped = Math.max(0, Math.min(steps(), pos)); + if (slider != null) slider.setValue(clamped); + if (scrollBar != null) scrollBar.setValue(clamped); } finally { - suppressSlider = false; + syncing = false; } } - private double fromSlider(final int pos) { - final double span = max.doubleValue() - min.doubleValue(); - return min.doubleValue() + span * pos / steps(); + 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. */ diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java index f6ff6f27a..9c6237654 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java @@ -114,7 +114,19 @@ protected void doRefresh() { private void changed() { final String s = text.getText(); - if (Widgets.type(node()) == String.class) update(s); - else if (!s.isEmpty()) update(s.charAt(0)); // NB: a char parameter + 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/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java index 799454112..8e2553e8c 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java @@ -47,7 +47,12 @@ public class SwingTextWidgetFactory implements SwingWidgetFactory { @Override public boolean supports(final ParameterNode node) { - return node.choices().isEmpty() && Widgets.isText(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 diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java index 02a699579..6128a1306 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java @@ -45,12 +45,15 @@ public abstract class SwingWidget implements Widget { private final ParameterNode node; private final ParameterModel model; + private final String key; private boolean refreshing; protected SwingWidget(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); } /** Gets the component to place in the dialog. */ @@ -66,7 +69,13 @@ public boolean isLabeled() { @Override public ParameterNode node() { - return 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 that the other widgets now imply. + return model.tree().find(key).orElse(node); } /** Gets the values this widget reads and writes. */ @@ -96,12 +105,13 @@ public final void refresh() { /** Gets the parameter's name. */ protected String key() { - return node.member().orElseThrow().member().key(); + if (key == null) throw new IllegalStateException("Not a parameter widget"); + return key; } /** Gets the parameter's current value. */ protected Object value() { - return node.member().orElseThrow().get(); + return model.get(key()); } /** diff --git a/scijava-swing/src/test/java/org/scijava/swing/Demos.java b/scijava-swing/src/test/java/org/scijava/swing/Demos.java index 99daa7dbe..b9f2cac0c 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/Demos.java +++ b/scijava-swing/src/test/java/org/scijava/swing/Demos.java @@ -84,15 +84,35 @@ private Demos() { /** 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") + @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; @@ -116,12 +136,19 @@ public static class KitchenSink implements Runnable { @Override public void run() { - summary = name + " " + percent + "% w=" + weight + " " + statistic + + 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") @@ -281,6 +308,67 @@ private void fahrenheitChanged() { 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; + } + } + // -- Launcher -- private static final Map> DEMOS = @@ -293,6 +381,7 @@ public void run() {} DEMOS.put("Computed choices", ComputedChoices.class); DEMOS.put("Live validation", LiveValidation.class); DEMOS.put("Linked values", LinkedValues.class); + DEMOS.put("Lock aspect ratio", LockAspectRatio.class); } public static void main(final String... args) { diff --git a/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java b/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java index f8a521e89..5b360d4dd 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java +++ b/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java @@ -60,6 +60,7 @@ static boolean isHeadless() { } private static final List> FACTORIES = List.of( // + new SwingMessageWidgetFactory(), // new SwingChoiceWidgetFactory(), // new SwingNumberWidgetFactory(), // new SwingToggleWidgetFactory(), // @@ -72,9 +73,15 @@ public void testWidgetPerParameterType() { final SwingPanel panel = panel(model); assertEquals(List.of( // + SwingMessageWidget.class, // message SwingTextWidget.class, // name SwingNumberWidget.class, // percent + SwingNumberWidget.class, // iterations SwingNumberWidget.class, // weight + SwingChoiceWidget.class, // method, an enum + SwingNumberWidget.class, // boxed, an Integer rather than an int + SwingTextWidget.class, // exact, a BigDecimal a spinner cannot step + SwingTextWidget.class, // initial, a char SwingToggleWidget.class, // enabled SwingChoiceWidget.class, // statistic SwingFileWidget.class, // input @@ -128,6 +135,50 @@ public void testComputedChoicesFollowAnotherValue() { .choices()); } + /** An enum offers its own constants, with no annotation. */ + @Test + public void testEnumChoices() { + final ParameterModel model = model(new Demos.KitchenSink()); + final SwingChoiceWidget method = assertInstanceOf(SwingChoiceWidget.class, + panel(model).widgets().get(5)); + assertEquals(List.of(Demos.Method.values()), method.node().choices()); + } + + /** + * 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 the dialog's shape has + * not changed and nothing was rebuilt. + */ + @Test + public void testWidgetSeesRecomputedChoices() { + final ParameterModel model = model(new Demos.ComputedChoices()); + final SwingPanel panel = panel(model); + final SwingChoiceWidget column = assertInstanceOf(SwingChoiceWidget.class, + 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 third value decides whether two others move together. */ + @Test + public void testLockedAspectRatio() { + final ParameterModel model = model(new Demos.LockAspectRatio()); + + model.set("width", 400); + assertEquals(300, model.get("height")); + + model.set("lock", false); + model.set("width", 800); + assertEquals(300, model.get("height")); + } + /** A widget writes through the model, so callbacks run. */ @Test public void testWidgetEditRunsCallback() { diff --git a/scijava-ui3/pom.xml b/scijava-ui3/pom.xml index 04f3b6b6a..85e2be36c 100644 --- a/scijava-ui3/pom.xml +++ b/scijava-ui3/pom.xml @@ -120,6 +120,12 @@ ${scijava-struct.version}
+ + + org.slf4j + slf4j-api + + org.junit.jupiter diff --git a/scijava-ui3/src/main/java/module-info.java b/scijava-ui3/src/main/java/module-info.java index 749342f82..fdd9e691b 100644 --- a/scijava-ui3/src/main/java/module-info.java +++ b/scijava-ui3/src/main/java/module-info.java @@ -35,5 +35,6 @@ requires transitive org.scijava.harvest; requires transitive org.scijava.common3; requires org.scijava.priority; + requires org.slf4j; } diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java index 20b26824c..a493b11dd 100644 --- a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java +++ b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java @@ -35,6 +35,8 @@ 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. @@ -49,6 +51,9 @@ */ public class WidgetPanels { + private static final Logger log = LoggerFactory.getLogger( + WidgetPanels.class); + private final List> factories; private final WidgetPanelFactory panels; @@ -85,7 +90,10 @@ public W build(final ParameterNode node, final ParameterModel model) { 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. + // 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; } diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java b/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java index 166d4f2d8..e7ab5f7c0 100644 --- a/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java +++ b/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java @@ -126,6 +126,24 @@ 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); From 13849965d3a1d0943a8750355a17fe1f13fca7c1 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 17:34:53 -0500 Subject: [PATCH 37/59] Ignore Eclipse's .factorypath Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 46a5f3e908859ae36fc0227ebc758b749b4871ff Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 18:49:47 -0500 Subject: [PATCH 38/59] migration.md: the command row was ported two commits ago Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration.md b/docs/migration.md index ca3c53988..447113031 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -122,7 +122,7 @@ A **dropped** row is a successful outcome, not a gap. | `thread` | EDT dispatch belongs with the UI layer (Phase 4); parallelism is `org.scijava.concurrent` | planned | | `convert` | `scijava-convert3` (Phase 2) | planned | | `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` | still to come: a command is a struct plus menu metadata plus `run` | planned | +| `command` | `org.scijava.command`: `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`; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `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.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 | From 193a373ce55035f867e6c34efa5f0b89b34514c4 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 19:00:16 -0500 Subject: [PATCH 39/59] A Swing shell: menus, search, status -- the first whole-stack demo `MenuCreator` and `Menus` go in scijava-command, beside the `MenuTree` they walk: descend the tree, a menu per branch and an item per leaf, so a toolkit implements four methods and inherits the walk. Two type parameters, because a menu bar and a menu are different types in every toolkit worth supporting -- and separate method names, because two overloads would have had the same erasure. `SwingMenus` is the first implementation: labels, icons and accelerators in ImageJ's notation (`^O`, `^+C`, where `^` is command on a Mac) as well as Swing's own. It loads no command class -- everything it needs came out of the annotation index. `Shell`, in the test sources, is the application: menus, a search bar that reaches commands in no menu at all, a status line and a progress bar. Choosing a command runs it through `Runner` with the Swing harvester, so the dialog work arrives here intact. mvn -o -pl scijava-swing test-compile exec:java -Denforcer.skip \ -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.swing.Shell Also recorded in the plan: what the retired `fijifx` experiment asked and this design answers -- chiefly that menus without class loading did *not* require every menu item to be a script, and that its `AppLoader` sketch is the shape the legacy bridge should take. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 30 +++ docs/migration.md | 1 + .../java/org/scijava/command/MenuCreator.java | 72 ++++++ .../main/java/org/scijava/command/Menus.java | 71 ++++++ scijava-swing/pom.xml | 6 + scijava-swing/src/main/java/module-info.java | 1 + .../java/org/scijava/swing/SwingMenus.java | 154 +++++++++++ .../test/java/org/scijava/swing/Shell.java | 240 ++++++++++++++++++ .../java/org/scijava/swing/ShellCommands.java | 200 +++++++++++++++ .../org/scijava/swing/SwingMenusTest.java | 152 +++++++++++ 10 files changed, 927 insertions(+) create mode 100644 scijava-command/src/main/java/org/scijava/command/MenuCreator.java create mode 100644 scijava-command/src/main/java/org/scijava/command/Menus.java create mode 100644 scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java create mode 100644 scijava-swing/src/test/java/org/scijava/swing/Shell.java create mode 100644 scijava-swing/src/test/java/org/scijava/swing/ShellCommands.java create mode 100644 scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 54eb9d72a..a3d631627 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -887,6 +887,36 @@ after it. 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-command`, 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. + - **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 diff --git a/docs/migration.md b/docs/migration.md index 447113031..afc124c7e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -126,6 +126,7 @@ A **dropped** row is a successful outcome, not a gap. | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `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.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.command`: `MenuTree` is the model and `MenuCreator`/`Menus` the toolkit-free walk, with `SwingMenus` the first implementation. `MenuService` has no equivalent: a menu bar is `MenuTree.of(Commands.discover(context))`, which is a line rather than a service | ported | | `ui`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/scijava-command/src/main/java/org/scijava/command/MenuCreator.java b/scijava-command/src/main/java/org/scijava/command/MenuCreator.java new file mode 100644 index 000000000..133032c48 --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/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.command; + +/** + * 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/scijava-command/src/main/java/org/scijava/command/Menus.java b/scijava-command/src/main/java/org/scijava/command/Menus.java new file mode 100644 index 000000000..0f063b2ad --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/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.command; + +/** + * 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/scijava-swing/pom.xml b/scijava-swing/pom.xml index 7f89200fd..6b4deffec 100644 --- a/scijava-swing/pom.xml +++ b/scijava-swing/pom.xml @@ -86,6 +86,7 @@ SciJava developers. + 1.0.0-SNAPSHOT 1.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.0-SNAPSHOT @@ -103,6 +104,11 @@ scijava-common3 ${scijava-common3.version}
+ + org.scijava + scijava-command + ${scijava-command.version} + org.scijava scijava-context diff --git a/scijava-swing/src/main/java/module-info.java b/scijava-swing/src/main/java/module-info.java index 65fea482b..82a6d5dec 100644 --- a/scijava-swing/src/main/java/module-info.java +++ b/scijava-swing/src/main/java/module-info.java @@ -37,6 +37,7 @@ requires transitive java.desktop; requires transitive org.scijava.ui3; + requires transitive org.scijava.command; requires transitive org.scijava.context; requires org.scijava.discovery; requires org.scijava.priority; diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java new file mode 100644 index 000000000..c33239b3f --- /dev/null +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java @@ -0,0 +1,154 @@ +/* + * #%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.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.command.CommandInfo; +import org.scijava.command.MenuCreator; +import org.scijava.command.MenuTree; +import org.scijava.command.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.command().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 -- + + /** + * Reads an accelerator. + *

+ * {@code ^} is the platform's menu shortcut - control, or command on a Mac - + * as ImageJ has always written it; {@code !} is alt and {@code +} is shift. + * Anything else is handed to {@link KeyStroke#getKeyStroke(String)}, so + * {@code "control shift N"} works too. + *

+ * + * @return the keystroke, or null if it cannot be read + */ + static KeyStroke keyStroke(final String accelerator) { + if (accelerator == null || accelerator.isEmpty()) return null; + int modifiers = 0; + int i = 0; + for (; i < accelerator.length(); i++) { + final char c = accelerator.charAt(i); + if (c == '^') modifiers |= Toolkit.getDefaultToolkit() + .getMenuShortcutKeyMaskEx(); + else if (c == '!') modifiers |= InputEvent.ALT_DOWN_MASK; + else if (c == '+') modifiers |= InputEvent.SHIFT_DOWN_MASK; + else break; + } + final String key = accelerator.substring(i); + if (modifiers == 0) return KeyStroke.getKeyStroke(key); + if (key.length() != 1) { + log.warn("Unreadable accelerator: {}", accelerator); + return null; + } + return KeyStroke.getKeyStroke(KeyEvent.getExtendedKeyCodeForChar(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/scijava-swing/src/test/java/org/scijava/swing/Shell.java b/scijava-swing/src/test/java/org/scijava/swing/Shell.java new file mode 100644 index 000000000..f6ad81b26 --- /dev/null +++ b/scijava-swing/src/test/java/org/scijava/swing/Shell.java @@ -0,0 +1,240 @@ +/* + * #%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.util.List; +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.command.CommandInfo; +import org.scijava.command.Commands; +import org.scijava.command.MenuTree; +import org.scijava.context.Context; +import org.scijava.execute.ExecutionResult; +import org.scijava.execute.Runner; + +/** + * 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 + * {@link ShellCommands} and found through the annotation index, 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 = Commands.discover(context); + 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 -- + + 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.className()).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/scijava-swing/src/test/java/org/scijava/swing/ShellCommands.java b/scijava-swing/src/test/java/org/scijava/swing/ShellCommands.java new file mode 100644 index 000000000..ebc33d093 --- /dev/null +++ b/scijava-swing/src/test/java/org/scijava/swing/ShellCommands.java @@ -0,0 +1,200 @@ +/* + * #%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 java.util.List; + +import org.scijava.command.Command; +import org.scijava.command.Menu; +import org.scijava.context.Plugin; +import org.scijava.execute.Parameter; +import org.scijava.harvest.Group; +import org.scijava.struct.ItemIO; + +/** + * The commands the demo shell puts in its menus. + *

+ * Nothing here knows that a menu, a dialog or Swing exists: each declares what + * it needs and what it produces, and is found through the annotation index. + *

+ *

+ * NB: the weights put the menus in the familiar order. A branch takes the + * weight of its lightest leaf, so it is enough for one command in each + * top-level menu to say where that menu belongs. + *

+ * + * @author Curtis Rueden + */ +public final class ShellCommands { + + private ShellCommands() { + // prevent instantiation of utility class + } + + @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 scijava-swing."; + + @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/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java b/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java new file mode 100644 index 000000000..b4cdea7f6 --- /dev/null +++ b/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java @@ -0,0 +1,152 @@ +/* + * #%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 static org.junit.jupiter.api.Assertions.assertTrue; + +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 java.util.stream.Collectors; + +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.command.CommandInfo; +import org.scijava.command.Commands; +import org.scijava.command.MenuTree; +import org.scijava.context.Context; + +/** + * Tests that the menu bar matches the commands the index describes. + * + * @author Curtis Rueden + */ +@DisabledIf("isHeadless") +public class SwingMenusTest { + + static boolean isHeadless() { + return GraphicsEnvironment.isHeadless(); + } + + /** The menus come out in the declared order, not alphabetically. */ + @Test + public void testMenuOrder() { + final JMenuBar bar = menuBar(); + assertEquals(List.of("File", "Image", "Process", "Analyze", "Help"), + labels(bar)); + } + + /** A menu path becomes menus, however deep, and a command becomes an item. */ + @Test + public void testNesting() { + final JMenuBar bar = menuBar(); + final JMenu image = menu(bar, "Image"); + assertEquals(List.of("Adjust"), labels(image)); + final JMenu adjust = (JMenu) image.getItem(0); + assertEquals(List.of("Brightness/Contrast..."), labels(adjust)); + } + + /** A command with no menu path is absent from the menus, not lost. */ + @Test + public void testCommandWithNoMenuPath() { + final List commands = commands(); + assertTrue(commands.stream().anyMatch(c -> c.className().endsWith( + "ShellCommands$Hidden")), "the hidden command should be discovered"); + assertEquals(List.of("About..."), labels(menu(menuBar(), "Help"))); + } + + /** 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) menu(menuBar(), "File").getItem(0); + assertNotNull(open.getAccelerator()); + } + + // -- Helper methods -- + + private static List commands() { + try (Context context = Context.create()) { + return Commands.discover(context); + } + } + + private static JMenuBar menuBar() { + // NB: the commands under test are the shell's; anything else another + // test contributes would only make this brittle. + final List mine = commands().stream() // + .filter(c -> c.className().contains("ShellCommands")) // + .collect(Collectors.toList()); + return SwingMenus.create(MenuTree.of(mine), command -> {}); + } + + 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 List labels(final JMenuBar bar) { + final List labels = new ArrayList<>(); + for (int i = 0; i < bar.getMenuCount(); i++) + labels.add(bar.getMenu(i).getText()); + return labels; + } + + private static List labels(final JMenu menu) { + final List labels = new ArrayList<>(); + for (int i = 0; i < menu.getItemCount(); i++) + labels.add(menu.getItem(i).getText()); + return labels; + } +} From 19e9d00fce98df4a2892ea88723b477e194b05e7 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 19:57:30 -0500 Subject: [PATCH 40/59] scijava-javafx: a second binding, and what it proved "Toolkit-agnostic" was an assertion. A second implementation makes it a measurement, and the measurement is good: the whole of scijava-ui3 survived JavaFX with two changes, both of them Swing keeping something that was never Swing's. - `Widget.isLabeled()` -- whether a parameter wants a label beside it is a statement about the parameter. Every toolkit would have answered it identically, so every toolkit should not have had to. - `WidgetPanel.showProblems(Map)` -- how a problem looks is a toolkit's business; that there is one, shown per parameter and cleared when fixed, is not. Nothing else moved. `Widget`, `WidgetFactory`, `WidgetPanelFactory`, `WidgetPanels`, `Widgets`, `InputHarvester`, `MenuCreator` and `Menus` were untouched, and the model layer never came into question. What JavaFX forced that Swing never would: - The toolkit must be started, exactly once, and there is no `invokeAndWait`. `FxThread` is that plumbing, and it avoids `Application.launch` on purpose: that method seizes the process and returns when the last window closes, which is wrong for a binding an application, a script or a test may drive. - `showAndWait` runs a nested event loop, so the dialog is 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 -- and an editable one discards what was typed unless Enter is pressed. Both are handled in `FxNumber`; neither has a Swing counterpart. - Some things are easier: a collapsible group is a `TitledPane`, and `KeyCombination.SHORTCUT_DOWN` says what Swing computes through a `Toolkit` call. And so that the two cannot drift, `scijava-ui3-test`: the sample commands both bindings render, and a conformance suite both extend -- eight assertions about widgets, three about menus. A binding's own tests now cover only what is genuinely its own. The Swing demos and shell moved onto the shared commands, which is why they look edited here. `FxShell` is the same small application as the Swing one: mvn -pl scijava-javafx test-compile exec:java -Denforcer.skip \ -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.javafx.FxShell Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 43 +- docs/migration.md | 1 + pom.xml | 2 + scijava-javafx/pom.xml | 249 +++++++++ scijava-javafx/src/main/java/module-info.java | 48 ++ .../java/org/scijava/javafx/FxChoice.java | 116 +++++ .../java/org/scijava/javafx/FxDialog.java | 187 +++++++ .../main/java/org/scijava/javafx/FxFile.java | 115 ++++ .../org/scijava/javafx/FxInputHarvester.java | 119 +++++ .../main/java/org/scijava/javafx/FxMenus.java | 146 ++++++ .../java/org/scijava/javafx/FxMessage.java | 95 ++++ .../java/org/scijava/javafx/FxNumber.java | 230 ++++++++ .../main/java/org/scijava/javafx/FxPanel.java | 165 ++++++ .../main/java/org/scijava/javafx/FxText.java | 125 +++++ .../java/org/scijava/javafx/FxThread.java | 133 +++++ .../java/org/scijava/javafx/FxToggle.java | 85 +++ .../java/org/scijava/javafx/FxWidget.java | 119 +++++ .../org/scijava/javafx/FxWidgetFactory.java | 45 ++ .../java/org/scijava/javafx/FxMenusTest.java | 85 +++ .../test/java/org/scijava/javafx/FxShell.java | 192 +++++++ .../org/scijava/javafx/FxWidgetsTest.java | 140 +++++ scijava-swing/pom.xml | 7 + .../java/org/scijava/swing/SwingPanel.java | 7 +- .../java/org/scijava/swing/SwingWidget.java | 5 +- .../test/java/org/scijava/swing/Demos.java | 316 +---------- .../test/java/org/scijava/swing/Shell.java | 2 +- .../java/org/scijava/swing/ShellCommands.java | 200 ------- .../org/scijava/swing/SwingMenusTest.java | 85 ++- .../org/scijava/swing/SwingWidgetsTest.java | 194 ++----- scijava-ui3-test/pom.xml | 206 ++++++++ .../src/main/java/module-info.java | 39 ++ .../org/scijava/ui3/test/MenuConformance.java | 109 ++++ .../org/scijava/ui3/test/SampleCommands.java | 492 ++++++++++++++++++ .../scijava/ui3/test/WidgetConformance.java | 227 ++++++++ .../src/main/java/org/scijava/ui3/Widget.java | 14 + .../java/org/scijava/ui3/WidgetPanel.java | 21 + 36 files changed, 3636 insertions(+), 728 deletions(-) create mode 100644 scijava-javafx/pom.xml create mode 100644 scijava-javafx/src/main/java/module-info.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxChoice.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxDialog.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxFile.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxMessage.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxNumber.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxPanel.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxText.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxToggle.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java create mode 100644 scijava-javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java create mode 100644 scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java create mode 100644 scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java create mode 100644 scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java delete mode 100644 scijava-swing/src/test/java/org/scijava/swing/ShellCommands.java create mode 100644 scijava-ui3-test/pom.xml create mode 100644 scijava-ui3-test/src/main/java/module-info.java create mode 100644 scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java create mode 100644 scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java create mode 100644 scijava-ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index a3d631627..f9267e532 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -689,8 +689,8 @@ 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** (to build) | `scijava-command` (a struct plus presentation metadata), `scijava-menu` (the menu tree), `scijava-ui3` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | -| **2. Toolkit bindings** | `scijava-ui-swing`, JavaFX, headless | layer 1 | +| **1. Application model** (built, less `scijava-desktop`) | `scijava-command` (a struct plus presentation metadata, the menu tree and its walk), `scijava-ui3` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | +| **2. Toolkit bindings** | `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 @@ -917,6 +917,45 @@ after it. 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. + +- **`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 diff --git a/docs/migration.md b/docs/migration.md index afc124c7e..271774a39 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -125,6 +125,7 @@ A **dropped** row is a successful outcome, not a gap. | `command` | `org.scijava.command`: `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`; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `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.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.command`: `MenuTree` is the model and `MenuCreator`/`Menus` the toolkit-free walk, with `SwingMenus` the first implementation. `MenuService` has no equivalent: a menu bar is `MenuTree.of(Commands.discover(context))`, which is a line rather than a service | ported | | `ui`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/pom.xml b/pom.xml index 95c4ea07b..cda3d47e2 100644 --- a/pom.xml +++ b/pom.xml @@ -71,6 +71,7 @@ scijava-harvest scijava-index scijava-io3 + scijava-javafx scijava-legacy scijava-meta scijava-ops-api @@ -89,6 +90,7 @@ scijava-testutil scijava-types scijava-ui3 + scijava-ui3-test diff --git a/scijava-javafx/pom.xml b/scijava-javafx/pom.xml new file mode 100644 index 000000000..822eba8a9 --- /dev/null +++ b/scijava-javafx/pom.xml @@ -0,0 +1,249 @@ + + + 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 + + + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.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-command + ${scijava-command.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-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/scijava-javafx/src/main/java/module-info.java b/scijava-javafx/src/main/java/module-info.java new file mode 100644 index 000000000..232fc58c2 --- /dev/null +++ b/scijava-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.command; + requires transitive org.scijava.context; + requires org.scijava.discovery; + requires org.scijava.priority; + requires org.slf4j; + +} diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxChoice.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxChoice.java new file mode 100644 index 000000000..4848bb0d8 --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxDialog.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxDialog.java new file mode 100644 index 000000000..9fc4797cd --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxFile.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxFile.java new file mode 100644 index 000000000..f57412714 --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java new file mode 100644 index 000000000..966b0c383 --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java new file mode 100644 index 000000000..bbc3c232b --- /dev/null +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java @@ -0,0 +1,146 @@ +/* + * #%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.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.command.CommandInfo; +import org.scijava.command.MenuCreator; +import org.scijava.command.MenuTree; +import org.scijava.command.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.command().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 -- + + /** + * Reads an accelerator, in the same notation the Swing binding accepts: + * {@code ^} for the platform's menu shortcut, {@code !} for alt, {@code +} + * for shift. + * + * @return the combination, or null if it cannot be read + */ + static KeyCombination accelerator(final String accelerator) { + if (accelerator == null || accelerator.isEmpty()) return null; + final List modifiers = new java.util.ArrayList<>(); + int i = 0; + for (; i < accelerator.length(); i++) { + final char c = accelerator.charAt(i); + // NB: SHORTCUT_DOWN is JavaFX saying what Swing needs a Toolkit call + // for: control, or command on a Mac. + if (c == '^') modifiers.add(KeyCombination.SHORTCUT_DOWN); + else if (c == '!') modifiers.add(KeyCombination.ALT_DOWN); + else if (c == '+') modifiers.add(KeyCombination.SHIFT_DOWN); + else break; + } + final String key = accelerator.substring(i); + if (modifiers.isEmpty()) { + try { + return KeyCombination.keyCombination(key); + } + catch (final IllegalArgumentException exc) { + log.warn("Unreadable accelerator: {}", accelerator); + return null; + } + } + if (key.length() != 1) { + log.warn("Unreadable accelerator: {}", accelerator); + return null; + } + return new KeyCharacterCombination(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/scijava-javafx/src/main/java/org/scijava/javafx/FxMessage.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxMessage.java new file mode 100644 index 000000000..7fc67f46d --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxNumber.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxNumber.java new file mode 100644 index 000000000..09d647a37 --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxPanel.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxPanel.java new file mode 100644 index 000000000..3355f99f2 --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxText.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxText.java new file mode 100644 index 000000000..f1393e298 --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java new file mode 100644 index 000000000..d8c9c0c42 --- /dev/null +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java @@ -0,0 +1,133 @@ +/* + * #%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 { + + private static boolean started; + + 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; + 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(); + } + await(ready); + // 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; + } + + /** 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(); + } + }); + 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); + } + } +} diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxToggle.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxToggle.java new file mode 100644 index 000000000..93689747c --- /dev/null +++ b/scijava-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/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java new file mode 100644 index 000000000..76931a682 --- /dev/null +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.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 javafx.scene.Node; + +import org.scijava.harvest.ParameterModel; +import org.scijava.harvest.ParameterNode; +import org.scijava.ui3.Widget; + +/** + * A {@link Widget} made of JavaFX controls. + *

+ * NB: deliberately a near-copy of {@code org.scijava.swing.SwingWidget}, and + * that is the finding: what a binding has to write is the toolkit-specific + * part and a few lines of bookkeeping, not any part of the model. + *

+ * + * @author Curtis Rueden + */ +public abstract class FxWidget implements Widget { + + private final ParameterNode node; + private final ParameterModel model; + private final String key; + + private boolean refreshing; + + protected FxWidget(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); + } + + /** Gets the control to place in the dialog. */ + public abstract Node control(); + + @Override + public boolean isLabeled() { + return true; + } + + @Override + public ParameterNode node() { + if (key == null) return node; // NB: a group, or the whole dialog + // NB: the tree is rebuilt on every change, so the node handed to this + // widget at construction goes stale; the current one is the one to ask. + 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. 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. */ + protected void update(final Object value) { + if (refreshing) return; + model.set(key(), value); + } +} diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java new file mode 100644 index 000000000..ba4fb3da0 --- /dev/null +++ b/scijava-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/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java new file mode 100644 index 000000000..cc7edf4f7 --- /dev/null +++ b/scijava-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.command.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/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java new file mode 100644 index 000000000..1e76e6955 --- /dev/null +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java @@ -0,0 +1,192 @@ +/* + * #%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.command.CommandInfo; +import org.scijava.command.Commands; +import org.scijava.command.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 = Commands.discover(context); + 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 -- + + 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.className()).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/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java new file mode 100644 index 000000000..d86e6ee28 --- /dev/null +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java @@ -0,0 +1,140 @@ +/* + * #%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.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)); + } + } + + /** A widget's own update path runs the callback, not only the model's. */ + @Test + public void testWidgetEditRunsCallback() { + final ParameterModel model = model(new SampleCommands.LinkedValues()); + final FxPanel panel = (FxPanel) panel(model); + final FxNumber celsius = (FxNumber) panel.widgets().get(0); + + FxThread.runAndWait(() -> celsius.update(100.0)); + + assertEquals(212.0, model.get("fahrenheit")); + } + + /** 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/scijava-swing/pom.xml b/scijava-swing/pom.xml index 6b4deffec..de5ca7efe 100644 --- a/scijava-swing/pom.xml +++ b/scijava-swing/pom.xml @@ -91,6 +91,7 @@ 1.0.1-SNAPSHOT 1.0.0-SNAPSHOT 3.0.0-SNAPSHOT + 3.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.0-SNAPSHOT 1.0.0-SNAPSHOT @@ -157,6 +158,12 @@ + + org.scijava + scijava-ui3-test + ${scijava-ui3-test.version} + test + + + true + + 1.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-command + ${scijava-command.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/scijava-ui3-test/src/main/java/module-info.java b/scijava-ui3-test/src/main/java/module-info.java new file mode 100644 index 000000000..8330a3070 --- /dev/null +++ b/scijava-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.command; + requires transitive org.scijava.context; + requires transitive org.scijava.ui3; + requires transitive org.junit.jupiter.api; + +} diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java new file mode 100644 index 000000000..2a8b4c7f7 --- /dev/null +++ b/scijava-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.command.CommandInfo; +import org.scijava.command.Commands; +import org.scijava.command.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/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java new file mode 100644 index 000000000..a50016dad --- /dev/null +++ b/scijava-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.command.Command; +import org.scijava.command.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 scijava-swing."; + + @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/scijava-ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java new file mode 100644 index 000000000..91802acc6 --- /dev/null +++ b/scijava-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/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java b/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java index c0aade9d1..029753720 100644 --- a/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java +++ b/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java @@ -53,4 +53,18 @@ public interface Widget { /** 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/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java index 201adf468..061a80e8c 100644 --- a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java +++ b/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java @@ -30,6 +30,7 @@ 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. @@ -51,4 +52,24 @@ public interface WidgetPanel extends Widget { 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); + } + } + } } From ad4ed608e55aea3bb665c629155c654646281046 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 20:13:43 -0500 Subject: [PATCH 41/59] scijava-awt: a third binding, and where the shared code belongs Three is where agnosticism gets believable, and AWT is the case that tests it: no spinner, no slider, no titled border, no collapsible pane, no icons in menus, and a MenuShortcut that cannot say alt. The number widget is a text field and a scroll bar honoring min, max, stepSize and the soft bounds; a collapsible group is a button that hides a panel; an accelerator AWT cannot express is dropped rather than approximated into some other key. None of it asked the contracts to change. What it did change is where shared code lives: - `AbstractWidget` moves into scijava-ui3. 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` moves into scijava-command. Swing wants a KeyStroke, JavaFX a KeyCombination, 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. On the four-component question. scijava-ui-swing imported exactly six things from scijava-ui-awt -- the input, drag-and-drop and window event dispatchers, colors, windows, clipboard -- and not one widget, because java.awt.Choice and JComboBox have nothing in common. So Swing depends on AWT for the *platform* and never for widgets; `Edt` is the first instance, the dispatch thread being AWT's and SwingUtilities a forwarder to it. The widget types stay siblings on purpose: SwingWidget could extend AwtWidget, but discovery filters on widgetType(), so that would mean a pure-AWT application quietly filling its dialogs with Swing controls as soon as scijava-swing was on the classpath. AWT also settled a difference worth recording: TextComponent.setText does not notify text listeners, where a Swing document would. mvn -o -pl scijava-awt test-compile exec:java -Denforcer.skip \ -Dexec.classpathScope=test -Dexec.mainClass=org.scijava.awt.AwtShell Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 53 +++- docs/migration.md | 1 + pom.xml | 1 + scijava-awt/pom.xml | 231 ++++++++++++++++++ scijava-awt/src/main/java/module-info.java | 46 ++++ .../main/java/org/scijava/awt/AwtChoice.java | 126 ++++++++++ .../main/java/org/scijava/awt/AwtDialog.java | 185 ++++++++++++++ .../main/java/org/scijava/awt/AwtFile.java | 131 ++++++++++ .../org/scijava/awt/AwtInputHarvester.java | 121 +++++++++ .../main/java/org/scijava/awt/AwtMenus.java | 118 +++++++++ .../main/java/org/scijava/awt/AwtMessage.java | 94 +++++++ .../main/java/org/scijava/awt/AwtNumber.java | 193 +++++++++++++++ .../main/java/org/scijava/awt/AwtPanel.java | 193 +++++++++++++++ .../main/java/org/scijava/awt/AwtText.java | 124 ++++++++++ .../main/java/org/scijava/awt/AwtToggle.java | 85 +++++++ .../main/java/org/scijava/awt/AwtWidget.java | 62 +++++ .../org/scijava/awt/AwtWidgetFactory.java | 45 ++++ .../src/main/java/org/scijava/awt/Edt.java | 96 ++++++++ .../java/org/scijava/awt/AwtMenusTest.java | 126 ++++++++++ .../test/java/org/scijava/awt/AwtShell.java | 166 +++++++++++++ .../java/org/scijava/awt/AwtWidgetsTest.java | 169 +++++++++++++ .../java/org/scijava/command/Accelerator.java | 125 ++++++++++ .../main/java/org/scijava/javafx/FxMenus.java | 41 ++-- .../java/org/scijava/javafx/FxWidget.java | 77 +----- .../org/scijava/javafx/FxWidgetsTest.java | 26 +- .../java/org/scijava/swing/SwingMenus.java | 37 ++- .../java/org/scijava/swing/SwingWidget.java | 82 +------ .../org/scijava/swing/SwingWidgetsTest.java | 25 +- .../java/org/scijava/ui3/AbstractWidget.java | 128 ++++++++++ 29 files changed, 2712 insertions(+), 195 deletions(-) create mode 100644 scijava-awt/pom.xml create mode 100644 scijava-awt/src/main/java/module-info.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtChoice.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtDialog.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtFile.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtInputHarvester.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtMessage.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtNumber.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtPanel.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtText.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtToggle.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtWidget.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java create mode 100644 scijava-awt/src/main/java/org/scijava/awt/Edt.java create mode 100644 scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java create mode 100644 scijava-awt/src/test/java/org/scijava/awt/AwtShell.java create mode 100644 scijava-awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java create mode 100644 scijava-command/src/main/java/org/scijava/command/Accelerator.java create mode 100644 scijava-ui3/src/main/java/org/scijava/ui3/AbstractWidget.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index f9267e532..403ab6c33 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -690,7 +690,7 @@ unless it says there is no replacement at all, which it says explicitly. | --- | --- | --- | | **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-command` (a struct plus presentation metadata, the menu tree and its walk), `scijava-ui3` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | -| **2. Toolkit bindings** | `scijava-swing`, `scijava-javafx`, headless | layer 1 | +| **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 @@ -949,6 +949,57 @@ after it. 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-command`, 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 diff --git a/docs/migration.md b/docs/migration.md index 271774a39..a23dc0f29 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -125,6 +125,7 @@ A **dropped** row is a successful outcome, not a gap. | `command` | `org.scijava.command`: `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`; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | | `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.command`: `MenuTree` is the model and `MenuCreator`/`Menus` the toolkit-free walk, with `SwingMenus` the first implementation. `MenuService` has no equivalent: a menu bar is `MenuTree.of(Commands.discover(context))`, which is a line rather than a service | ported | diff --git a/pom.xml b/pom.xml index cda3d47e2..0d72a202d 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,7 @@ scijava-taglets + scijava-awt scijava-collections scijava-command scijava-common3 diff --git a/scijava-awt/pom.xml b/scijava-awt/pom.xml new file mode 100644 index 000000000..097d64637 --- /dev/null +++ b/scijava-awt/pom.xml @@ -0,0 +1,231 @@ + + + 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. + + + 1.0.0-SNAPSHOT + 1.0.0-SNAPSHOT + 1.0.1-SNAPSHOT + 1.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-command + ${scijava-command.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-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/scijava-awt/src/main/java/module-info.java b/scijava-awt/src/main/java/module-info.java new file mode 100644 index 000000000..e7fb462ae --- /dev/null +++ b/scijava-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.command; + requires transitive org.scijava.context; + requires org.scijava.discovery; + requires org.scijava.priority; + requires org.slf4j; + +} diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtChoice.java b/scijava-awt/src/main/java/org/scijava/awt/AwtChoice.java new file mode 100644 index 000000000..a35459833 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtDialog.java b/scijava-awt/src/main/java/org/scijava/awt/AwtDialog.java new file mode 100644 index 000000000..a9d012b12 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtFile.java b/scijava-awt/src/main/java/org/scijava/awt/AwtFile.java new file mode 100644 index 000000000..d03393341 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtInputHarvester.java b/scijava-awt/src/main/java/org/scijava/awt/AwtInputHarvester.java new file mode 100644 index 000000000..f39621bb3 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java new file mode 100644 index 000000000..4a7b4f624 --- /dev/null +++ b/scijava-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.command.Accelerator; +import org.scijava.command.CommandInfo; +import org.scijava.command.MenuCreator; +import org.scijava.command.MenuTree; +import org.scijava.command.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.command().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/scijava-awt/src/main/java/org/scijava/awt/AwtMessage.java b/scijava-awt/src/main/java/org/scijava/awt/AwtMessage.java new file mode 100644 index 000000000..e53faf8ff --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtNumber.java b/scijava-awt/src/main/java/org/scijava/awt/AwtNumber.java new file mode 100644 index 000000000..aba2c707e --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtPanel.java b/scijava-awt/src/main/java/org/scijava/awt/AwtPanel.java new file mode 100644 index 000000000..d41bb5b5f --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtText.java b/scijava-awt/src/main/java/org/scijava/awt/AwtText.java new file mode 100644 index 000000000..a142113dc --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtToggle.java b/scijava-awt/src/main/java/org/scijava/awt/AwtToggle.java new file mode 100644 index 000000000..d57c638c4 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtWidget.java b/scijava-awt/src/main/java/org/scijava/awt/AwtWidget.java new file mode 100644 index 000000000..0aadca074 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java b/scijava-awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java new file mode 100644 index 000000000..84ecc8329 --- /dev/null +++ b/scijava-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/scijava-awt/src/main/java/org/scijava/awt/Edt.java b/scijava-awt/src/main/java/org/scijava/awt/Edt.java new file mode 100644 index 000000000..42b885872 --- /dev/null +++ b/scijava-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/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java b/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java new file mode 100644 index 000000000..8e7fec215 --- /dev/null +++ b/scijava-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.command.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/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java new file mode 100644 index 000000000..6dbadd312 --- /dev/null +++ b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java @@ -0,0 +1,166 @@ +/* + * #%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.command.CommandInfo; +import org.scijava.command.Commands; +import org.scijava.command.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 = Commands.discover( + context); + 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 -- + + /** 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.className()).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/scijava-awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java b/scijava-awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java new file mode 100644 index 000000000..1c35afafe --- /dev/null +++ b/scijava-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/scijava-command/src/main/java/org/scijava/command/Accelerator.java b/scijava-command/src/main/java/org/scijava/command/Accelerator.java new file mode 100644 index 000000000..653abb16e --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/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.command; + +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/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java index bbc3c232b..0c3ae48ea 100644 --- a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java @@ -29,7 +29,9 @@ 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; @@ -41,6 +43,7 @@ import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyCombination.Modifier; +import org.scijava.command.Accelerator; import org.scijava.command.CommandInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; @@ -98,40 +101,38 @@ public void item(final MenuTree leaf, final Menu parent) { // -- Helper methods -- /** - * Reads an accelerator, in the same notation the Swing binding accepts: - * {@code ^} for the platform's menu shortcut, {@code !} for alt, {@code +} - * for shift. + * 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) { - if (accelerator == null || accelerator.isEmpty()) return null; - final List modifiers = new java.util.ArrayList<>(); - int i = 0; - for (; i < accelerator.length(); i++) { - final char c = accelerator.charAt(i); - // NB: SHORTCUT_DOWN is JavaFX saying what Swing needs a Toolkit call - // for: control, or command on a Mac. - if (c == '^') modifiers.add(KeyCombination.SHORTCUT_DOWN); - else if (c == '!') modifiers.add(KeyCombination.ALT_DOWN); - else if (c == '+') modifiers.add(KeyCombination.SHIFT_DOWN); - else break; - } - final String key = accelerator.substring(i); - if (modifiers.isEmpty()) { + final Optional parsed = Accelerator.parse(accelerator); + if (parsed.isEmpty()) return null; + final Accelerator a = parsed.get(); + if (!a.hasModifiers()) { try { - return KeyCombination.keyCombination(key); + return KeyCombination.keyCombination(a.key()); } catch (final IllegalArgumentException exc) { log.warn("Unreadable accelerator: {}", accelerator); return null; } } - if (key.length() != 1) { + if (!a.isCharacter()) { log.warn("Unreadable accelerator: {}", accelerator); return null; } - return new KeyCharacterCombination(key, modifiers.toArray( + 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])); } diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java index 76931a682..3ac60cca2 100644 --- a/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java @@ -33,87 +33,24 @@ import org.scijava.harvest.ParameterModel; import org.scijava.harvest.ParameterNode; -import org.scijava.ui3.Widget; +import org.scijava.ui3.AbstractWidget; /** - * A {@link Widget} made of JavaFX controls. + * A {@link org.scijava.ui3.Widget} made of JavaFX controls. *

- * NB: deliberately a near-copy of {@code org.scijava.swing.SwingWidget}, and - * that is the finding: what a binding has to write is the toolkit-specific - * part and a few lines of bookkeeping, not any part of the model. + * 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 implements Widget { - - private final ParameterNode node; - private final ParameterModel model; - private final String key; - - private boolean refreshing; +public abstract class FxWidget extends AbstractWidget { protected FxWidget(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); + super(node, model); } /** Gets the control to place in the dialog. */ public abstract Node control(); - - @Override - public boolean isLabeled() { - return true; - } - - @Override - public ParameterNode node() { - if (key == null) return node; // NB: a group, or the whole dialog - // NB: the tree is rebuilt on every change, so the node handed to this - // widget at construction goes stale; the current one is the one to ask. - 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. 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. */ - protected void update(final Object value) { - if (refreshing) return; - model.set(key(), value); - } } diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java index d86e6ee28..f74a7adec 100644 --- a/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java @@ -34,6 +34,9 @@ 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; @@ -113,18 +116,35 @@ protected Class widgetType(final Kind kind) { } } - /** A widget's own update path runs the callback, not only the model's. */ + /** + * 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 testWidgetEditRunsCallback() { + 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(() -> celsius.update(100.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() { diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java index c33239b3f..59ae93ab8 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java @@ -33,6 +33,7 @@ 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; @@ -41,6 +42,7 @@ import javax.swing.JMenuItem; import javax.swing.KeyStroke; +import org.scijava.command.Accelerator; import org.scijava.command.CommandInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; @@ -108,35 +110,30 @@ public void item(final MenuTree leaf, final JMenu parent) { // -- Helper methods -- /** - * Reads an accelerator. + * Turns an accelerator into a Swing keystroke. *

- * {@code ^} is the platform's menu shortcut - control, or command on a Mac - - * as ImageJ has always written it; {@code !} is alt and {@code +} is shift. - * Anything else is handed to {@link KeyStroke#getKeyStroke(String)}, so - * {@code "control shift N"} works too. + * 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) { - if (accelerator == null || accelerator.isEmpty()) return null; - int modifiers = 0; - int i = 0; - for (; i < accelerator.length(); i++) { - final char c = accelerator.charAt(i); - if (c == '^') modifiers |= Toolkit.getDefaultToolkit() - .getMenuShortcutKeyMaskEx(); - else if (c == '!') modifiers |= InputEvent.ALT_DOWN_MASK; - else if (c == '+') modifiers |= InputEvent.SHIFT_DOWN_MASK; - else break; - } - final String key = accelerator.substring(i); - if (modifiers == 0) return KeyStroke.getKeyStroke(key); - if (key.length() != 1) { + 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; } - return KeyStroke.getKeyStroke(KeyEvent.getExtendedKeyCodeForChar(key + 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); } diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java b/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java index fe72fea45..8574d7962 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java @@ -34,96 +34,22 @@ import org.scijava.harvest.ParameterModel; import org.scijava.harvest.ParameterNode; -import org.scijava.ui3.Widget; +import org.scijava.ui3.AbstractWidget; /** - * A {@link Widget} made of Swing components. + * A {@link org.scijava.ui3.Widget} made of Swing components. * * @author Curtis Rueden */ -public abstract class SwingWidget implements Widget { - - private final ParameterNode node; - private final ParameterModel model; - private final String key; - - private boolean refreshing; +public abstract class SwingWidget extends AbstractWidget { protected SwingWidget(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); + super(node, model); } /** Gets the component to place in the dialog. */ public abstract JComponent component(); - @Override - public boolean isLabeled() { - return true; - } - - @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 that 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 Swing component 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 components. */ - 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); - } - /** Makes a panel with no padding, for a widget of several components. */ protected static JPanel panel() { final JPanel panel = new JPanel(); diff --git a/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java b/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java index 47acd18f3..0b2bada49 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java +++ b/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java @@ -32,9 +32,13 @@ 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; @@ -91,19 +95,34 @@ protected Class widgetType(final Kind kind) { } } - /** A widget's own update path runs the callback, not only the model's. */ + /** + * 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 testWidgetEditRunsCallback() { + 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)); - celsius.update(100.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() { diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/AbstractWidget.java b/scijava-ui3/src/main/java/org/scijava/ui3/AbstractWidget.java new file mode 100644 index 000000000..22de0cfe3 --- /dev/null +++ b/scijava-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); + } +} From ada989441e3b46fa7a320cbfff5918f329e90185 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 21:11:47 -0500 Subject: [PATCH 42/59] scijava-convert3: one question, and it sits below the container Binding an input to a parameter converts, and a script, a command line and a text field all have only strings to offer -- so conversion cannot be something only a container has. scijava-convert3 depends on nothing but scijava-common3 and scijava-priority, and finds its converters through plain ServiceLoader. `Converter.supports(Object, Type)` asks whether *this value* can become *that type*. SciJava Common had eight `canConvert` overloads across Object/Class/Type pairings, a ConversionRequest to carry the combinations, and an AbstractConverter to make that bearable. Declaring sourceType() and destType() answers the common case by itself, so Converter.of(String.class, Foo.class, Foo::parse) is a whole converter. DefaultConverter's sequence of special cases becomes one converter per concern -- cast, number, string-to-value, to-string, array, collection, file/path, and a wrapping constructor last -- each separately discoverable, orderable by priority, and replaceable by a component that disagrees, which a monolith never allowed. Unconvertible is not zero: tryConvert returns empty and convert throws, where SciJava Common returned null for both "cannot" and "converted to null". That distinction is what a widget needs to leave a parameter alone while the user is still typing -- and with it available, `Widgets.toType` is now one call into convert3 rather than a third copy of text parsing. A BigDecimal from a double goes through the string, so 0.1 stays 0.1 rather than the binary double nearest it. Wired in where it earns its keep: `Executables.bind` converts supplied inputs, so run(cmd, Map.of("a", "1.5")) reaches a `double a`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 39 ++++ docs/migration.md | 4 +- pom.xml | 1 + scijava-convert3/pom.xml | 154 ++++++++++++++ .../src/main/java/module-info.java | 51 +++++ .../org/scijava/convert3/ArrayConverter.java | 107 ++++++++++ .../org/scijava/convert3/CastConverter.java | 77 +++++++ .../scijava/convert3/CollectionConverter.java | 123 +++++++++++ .../convert3/ConstructorConverter.java | 103 +++++++++ .../scijava/convert3/ConversionException.java | 59 +++++ .../java/org/scijava/convert3/Converter.java | 150 +++++++++++++ .../java/org/scijava/convert3/Converters.java | 166 +++++++++++++++ .../org/scijava/convert3/NumberConverter.java | 108 ++++++++++ .../org/scijava/convert3/PathConverters.java | 135 ++++++++++++ .../org/scijava/convert3/StringConverter.java | 119 +++++++++++ .../scijava/convert3/ToStringConverter.java | 72 +++++++ .../services/org.scijava.convert3.Converter | 12 ++ .../org/scijava/convert3/ConvertersTest.java | 201 ++++++++++++++++++ scijava-execute/pom.xml | 6 + .../src/main/java/module-info.java | 1 + .../java/org/scijava/execute/Executables.java | 27 ++- .../org/scijava/execute/ExecutablesTest.java | 37 ++++ .../org/scijava/ui3/test/SampleCommands.java | 2 +- .../main/java/org/scijava/ui3/Widgets.java | 33 +-- 24 files changed, 1761 insertions(+), 26 deletions(-) create mode 100644 scijava-convert3/pom.xml create mode 100644 scijava-convert3/src/main/java/module-info.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/ArrayConverter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/CollectionConverter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/ConversionException.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/Converter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/Converters.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/NumberConverter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/PathConverters.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/StringConverter.java create mode 100644 scijava-convert3/src/main/java/org/scijava/convert3/ToStringConverter.java create mode 100644 scijava-convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter create mode 100644 scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 403ab6c33..f16352e92 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -758,6 +758,45 @@ ways, dependency flowing one way. That is what makes the migration incremental rather than a big bang, and it should be sequenced *with* layer 1 rather than after it. +### 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. + ### Phase 4 — UI and desktop - **`scijava-harvest`** (`org.scijava.harvest`): the model behind a parameter diff --git a/docs/migration.md b/docs/migration.md index a23dc0f29..d71962701 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -28,7 +28,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | `scijava-convert3` (Phase 2); already deprecated in SJC | planned | +| `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 | @@ -120,7 +120,7 @@ A **dropped** row is a successful outcome, not a gap. | `prefs` | split: a TOML settings store under `~/.config/fiji` (design settled in [migration-plan.md](migration-plan.md)), and widget value persistence, which belongs with the input harvester (Phase 4). Explicitly **not** `java.util.prefs` | planned | | `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` | `scijava-convert3` (Phase 2) | 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 | | `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.command`: `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`; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | | `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | diff --git a/pom.xml b/pom.xml index 0d72a202d..244641932 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ scijava-command scijava-common3 scijava-context + scijava-convert3 scijava-context-test scijava-concurrent scijava-discovery diff --git a/scijava-convert3/pom.xml b/scijava-convert3/pom.xml new file mode 100644 index 000000000..c378904a8 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/module-info.java b/scijava-convert3/src/main/java/module-info.java new file mode 100644 index 000000000..e3d1f8382 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/ArrayConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/ArrayConverter.java new file mode 100644 index 000000000..e0e13244d --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java new file mode 100644 index 000000000..cebaeabde --- /dev/null +++ b/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java @@ -0,0 +1,77 @@ +/* + * #%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.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; + return Classes.box(raw).isInstance(source); + } + + @Override + public Object convert(final Object source, final Type dest) { + return source; + } + + @Override + public double priority() { + return Priority.VERY_HIGH; + } +} diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/CollectionConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/CollectionConverter.java new file mode 100644 index 000000000..558ff2f0d --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java new file mode 100644 index 000000000..1c4d861e5 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/ConversionException.java b/scijava-convert3/src/main/java/org/scijava/convert3/ConversionException.java new file mode 100644 index 000000000..0e38bb284 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/Converter.java b/scijava-convert3/src/main/java/org/scijava/convert3/Converter.java new file mode 100644 index 000000000..f4c5100f9 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/Converters.java b/scijava-convert3/src/main/java/org/scijava/convert3/Converters.java new file mode 100644 index 000000000..3926b20f4 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/NumberConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/NumberConverter.java new file mode 100644 index 000000000..0e80a8d6a --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/PathConverters.java b/scijava-convert3/src/main/java/org/scijava/convert3/PathConverters.java new file mode 100644 index 000000000..2386adea6 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/StringConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/StringConverter.java new file mode 100644 index 000000000..6ec771b1c --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/java/org/scijava/convert3/ToStringConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/ToStringConverter.java new file mode 100644 index 000000000..340ab2bc3 --- /dev/null +++ b/scijava-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/scijava-convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter b/scijava-convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter new file mode 100644 index 000000000..c734bd51c --- /dev/null +++ b/scijava-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/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java b/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java new file mode 100644 index 000000000..9c5c73dee --- /dev/null +++ b/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java @@ -0,0 +1,201 @@ +/* + * #%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); + } + + /** 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-execute/pom.xml b/scijava-execute/pom.xml index 6ca455ab7..ab603607f 100644 --- a/scijava-execute/pom.xml +++ b/scijava-execute/pom.xml @@ -86,12 +86,18 @@ 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 diff --git a/scijava-execute/src/main/java/module-info.java b/scijava-execute/src/main/java/module-info.java index 96627f280..6f7efb75a 100644 --- a/scijava-execute/src/main/java/module-info.java +++ b/scijava-execute/src/main/java/module-info.java @@ -34,5 +34,6 @@ requires org.scijava.common3; requires transitive org.scijava.priority; requires transitive org.scijava.struct; + requires transitive org.scijava.convert3; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/scijava-execute/src/main/java/org/scijava/execute/Executables.java index 3f65e4648..aa63f197b 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Executables.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Executables.java @@ -29,11 +29,14 @@ 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; @@ -153,6 +156,28 @@ public java.util.Optional behavior(final String 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); @@ -234,7 +259,7 @@ static StructInstance bind(final StructInstance instance, if (!member.member().isInput()) continue; final String key = member.member().key(); if (inputs.containsKey(key)) { - member.set(inputs.get(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 diff --git a/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java b/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java index 03aa44b4f..32d5626cc 100644 --- a/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java +++ b/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java @@ -212,4 +212,41 @@ private static List describe(final Class type, .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/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java index a50016dad..3da3f7fca 100644 --- a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java +++ b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java @@ -473,7 +473,7 @@ public static class About implements Command { @Parameter(style = "message", required = false) private String about = "A SciJava3 application: menus from the " + - "annotation index, dialogs from scijava-swing."; + "annotation index, dialogs from whichever toolkit binding is running."; @Override public void run() {} diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java b/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java index e7ab5f7c0..a9efd55ac 100644 --- a/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java +++ b/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java @@ -29,10 +29,9 @@ package org.scijava.ui3; -import java.math.BigDecimal; -import java.math.BigInteger; import java.util.Optional; +import org.scijava.convert3.Converters; import org.scijava.execute.ParameterMember; import org.scijava.harvest.ParameterNode; import org.scijava.struct.MemberInstance; @@ -151,31 +150,21 @@ public static Number stepSize(final ParameterNode node) { } /** - * Reads a number in the given type. + * Reads a value in the given type, for a widget that has only text. *

- * NB: bounds arrive as strings, because metadata has to survive a script - * header; the widget wants them in the parameter's own type, so that a - * {@code long} parameter is not silently bounded by a {@code double}. + * 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 parsed number, or null if the text is not a number + * @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 || text.isEmpty()) return null; - try { - final Class t = box(type); - if (t == Byte.class) return Byte.valueOf(text); - if (t == Short.class) return Short.valueOf(text); - if (t == Integer.class) return Integer.valueOf(text); - if (t == Long.class) return Long.valueOf(text); - if (t == Float.class) return Float.valueOf(text); - if (t == BigInteger.class) return new BigInteger(text); - if (t == BigDecimal.class) return new BigDecimal(text); - return Double.valueOf(text); - } - catch (final NumberFormatException exc) { - return null; - } + 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. */ From 22c0dc6f2cc1be3938fd4abbfdd757ac5af809b0 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 21:24:11 -0500 Subject: [PATCH 43/59] scijava-script3: 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 rather than asserted: ScriptWidgetsTest renders a Groovy script's parameters with the Swing widgets and drives its callbacks through an actual JSpinner. 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 declare nothing. GraalVM polyglot and Appose become further ScriptLanguages without this layer changing. Callbacks work for scripts, which SciJava Common never managed. It resolved `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 it defined. Three things make it work: - a *session* rather than a one-shot eval, so the functions still exist when a parameter changes; - what the function changed is converted back into the parameters -- Groovy's `celsius * 9 / 5 + 32` is a BigDecimal, and convert3 knows what the parameter's declared type wants; - every declared parameter is bound, including unfilled ones, since a language that resolves names at run time fails on an unbound name rather than seeing null. Validators and choicesFrom come along for the same ride. The #@ header syntax is unchanged, because tens of thousands of scripts are written in it. Declaration lines are blanked rather than removed, so the language's own error messages keep pointing at the line the author is looking at -- #@ being a comment in Python and a syntax error in Groovy, leaving it in was not an option. 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. ScriptREPL, ScriptInterpreter, AutoCompleter and CodeGenerator belong with the script editor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 43 +++ docs/migration.md | 2 +- pom.xml | 1 + scijava-script3/pom.xml | 185 +++++++++++ .../src/main/java/module-info.java | 43 +++ .../java/org/scijava/script3/JsrLanguage.java | 144 ++++++++ .../org/scijava/script3/ScriptException.java | 53 +++ .../org/scijava/script3/ScriptExecutable.java | 231 +++++++++++++ .../org/scijava/script3/ScriptHeader.java | 307 ++++++++++++++++++ .../org/scijava/script3/ScriptLanguage.java | 66 ++++ .../org/scijava/script3/ScriptSession.java | 80 +++++ .../java/org/scijava/script3/Scripts.java | 160 +++++++++ .../org/scijava/script3/GroovyScriptTest.java | 212 ++++++++++++ .../org/scijava/script3/ScriptHeaderTest.java | 131 ++++++++ scijava-swing/pom.xml | 22 ++ .../org/scijava/swing/ScriptWidgetsTest.java | 191 +++++++++++ 16 files changed, 1870 insertions(+), 1 deletion(-) create mode 100644 scijava-script3/pom.xml create mode 100644 scijava-script3/src/main/java/module-info.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/JsrLanguage.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/ScriptException.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/ScriptExecutable.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/ScriptLanguage.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/ScriptSession.java create mode 100644 scijava-script3/src/main/java/org/scijava/script3/Scripts.java create mode 100644 scijava-script3/src/test/java/org/scijava/script3/GroovyScriptTest.java create mode 100644 scijava-script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java create mode 100644 scijava-swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index f16352e92..ec58c5709 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -797,6 +797,49 @@ 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. +- **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. + ### Phase 4 — UI and desktop - **`scijava-harvest`** (`org.scijava.harvest`): the model behind a parameter diff --git a/docs/migration.md b/docs/migration.md index d71962701..70bedd731 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -123,7 +123,7 @@ A **dropped** row is a successful outcome, not a gap. | `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 | | `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.command`: `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`; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | -| `script`, `script.process` | scripting facade + `javax.script` / GraalVM polyglot / Appose adapters (Phase 3) | planned | +| `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 | 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 | diff --git a/pom.xml b/pom.xml index 244641932..7d611fcdf 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ scijava-ops-tutorial scijava-priority scijava-progress + scijava-script3 scijava-spi scijava-struct scijava-swing diff --git a/scijava-script3/pom.xml b/scijava-script3/pom.xml new file mode 100644 index 000000000..e4c40164f --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/module-info.java b/scijava-script3/src/main/java/module-info.java new file mode 100644 index 000000000..04340cef0 --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/org/scijava/script3/JsrLanguage.java b/scijava-script3/src/main/java/org/scijava/script3/JsrLanguage.java new file mode 100644 index 000000000..331620799 --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/org/scijava/script3/ScriptException.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptException.java new file mode 100644 index 000000000..0d62893b8 --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/org/scijava/script3/ScriptExecutable.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptExecutable.java new file mode 100644 index 000000000..dfb41e6a0 --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java new file mode 100644 index 000000000..94e7a10c2 --- /dev/null +++ b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java @@ -0,0 +1,307 @@ +/* + * #%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.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 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. + if (declaration || SHEBANG.matcher(line).matches()) 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 the script's own metadata: its menu path, its label. */ + public Map directives() { + return Map.copyOf(directives); + } + + // -- 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/scijava-script3/src/main/java/org/scijava/script3/ScriptLanguage.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptLanguage.java new file mode 100644 index 000000000..b31358163 --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/org/scijava/script3/ScriptSession.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptSession.java new file mode 100644 index 000000000..9561563ff --- /dev/null +++ b/scijava-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/scijava-script3/src/main/java/org/scijava/script3/Scripts.java b/scijava-script3/src/main/java/org/scijava/script3/Scripts.java new file mode 100644 index 000000000..26cd9c072 --- /dev/null +++ b/scijava-script3/src/main/java/org/scijava/script3/Scripts.java @@ -0,0 +1,160 @@ +/* + * #%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 int dot = fileName.lastIndexOf('.'); + final String extension = dot < 0 ? "" : fileName.substring(dot + 1); + final ScriptLanguage language = language(extension).orElseThrow( + () -> new ScriptException("No language for '." + extension + + "'. Available: " + names())); + try { + final String code = new String(Files.readAllBytes(path), + StandardCharsets.UTF_8); + return new ScriptExecutable(fileName, code, language); + } + catch (final IOException exc) { + throw new ScriptException("Cannot read script: " + path, exc); + } + } + + /** + * 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) + { + final ScriptLanguage language = language(nameOrExtension).orElseThrow( + () -> new ScriptException("No such language: " + nameOrExtension + + ". Available: " + names())); + 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/scijava-script3/src/test/java/org/scijava/script3/GroovyScriptTest.java b/scijava-script3/src/test/java/org/scijava/script3/GroovyScriptTest.java new file mode 100644 index 000000000..74173cec4 --- /dev/null +++ b/scijava-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/scijava-script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java b/scijava-script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java new file mode 100644 index 000000000..ee2150318 --- /dev/null +++ b/scijava-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/scijava-swing/pom.xml b/scijava-swing/pom.xml index de5ca7efe..8bfc72667 100644 --- a/scijava-swing/pom.xml +++ b/scijava-swing/pom.xml @@ -92,6 +92,7 @@ 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 @@ -158,6 +159,27 @@
+ + + org.scijava + scijava-script3 + ${scijava-script3.version} + test + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + org.scijava scijava-ui3-test diff --git a/scijava-swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java b/scijava-swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java new file mode 100644 index 000000000..628b62c60 --- /dev/null +++ b/scijava-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); + } +} From 40d687dd00d311caac8553bf6fdd75371f917db4 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 21:45:04 -0500 Subject: [PATCH 44/59] Scripts declare their presentation, and menus take more than one source Answering "how does a script say where it belongs, and what language it is written in": - `#@script(menu = "Process>Filters>Blur It", accelerator = "^B", label, weight, iconPath)` -- the same keys @Menu uses. These directives were parsed already and consumed by nothing; now they mean something. - Where a script says nothing, the directory speaks for it: scripts/Process/Filters/Blur_It.groovy means the same thing, underscores read as spaces. That is how the scripts in the wild are arranged. - `#@script(language = "jython")` or a `#!` line settles which language is meant, for the ones sharing an extension -- .py being Jython or Python. Silence means the extension decides. Making that work needed the gap underneath filled. `MenuTree.of` took `Collection`, and a CommandInfo is class-and-index shaped; a script is not a class. So `ExecutableInfo` is now the question the menu layer asks -- menuPath, label, accelerator, iconPath, weight, visible -- and it extends Executable, so what a menu holds is the thing to run. `CommandInfo` implements it; `ExecutableInfo.of(executable, attrs)` makes one out of a script and its directives; a SciJava Common ModuleInfo will make one through the bridge, with no further change to the menu layer, the bindings or the shells. It is deliberately not called `MenuEntry`: a menu path is optional, and plenty of runnable things belong in no menu -- one invoked by name from a script, one reached only through the search bar, one that exists to be called by something else. MenuTree takes the subset with a path; CommandsTest pins that a pathless command is still labelled, still runnable, and simply absent from the tree. `ScriptFinder` reads a directory into scripts plus metadata maps -- not menu entries, since scijava-script3 knows nothing about menus. All three shells now build their menus from the annotation index *and* a directory of Groovy scripts, and nothing below them can tell which came from where. That is the AppLoader shape the fijifx notes sketched, now load-bearing rather than planned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 26 +++ docs/migration.md | 4 +- scijava-awt/pom.xml | 18 ++ .../main/java/org/scijava/awt/AwtMenus.java | 10 +- .../test/java/org/scijava/awt/AwtShell.java | 31 ++- .../scripts/Process/Filters/Blur_It.groovy | 3 + .../test/resources/scripts/Say_Hello.groovy | 4 + .../java/org/scijava/command/CommandInfo.java | 8 +- .../org/scijava/command/ExecutableInfo.java | 182 ++++++++++++++++++ .../java/org/scijava/command/MenuTree.java | 31 +-- .../org/scijava/command/CommandsTest.java | 26 +++ .../org/scijava/command/MenuTreeTest.java | 6 +- scijava-javafx/pom.xml | 18 ++ .../main/java/org/scijava/javafx/FxMenus.java | 10 +- .../test/java/org/scijava/javafx/FxShell.java | 36 +++- .../scripts/Process/Filters/Blur_It.groovy | 3 + .../test/resources/scripts/Say_Hello.groovy | 4 + .../org/scijava/script3/ScriptFinder.java | 162 ++++++++++++++++ .../org/scijava/script3/ScriptHeader.java | 45 ++++- .../java/org/scijava/script3/Scripts.java | 60 +++++- .../org/scijava/script3/ScriptFinderTest.java | 130 +++++++++++++ .../scripts/Process/Filters/Blur_It.groovy | 3 + .../test/resources/scripts/Say_Hello.groovy | 4 + .../java/org/scijava/swing/SwingMenus.java | 10 +- .../org/scijava/swing/ScriptMenuTest.java | 149 ++++++++++++++ .../test/java/org/scijava/swing/Shell.java | 58 +++++- .../scripts/Process/Filters/Blur_It.groovy | 3 + .../test/resources/scripts/Say_Hello.groovy | 4 + 28 files changed, 974 insertions(+), 74 deletions(-) create mode 100644 scijava-awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy create mode 100644 scijava-awt/src/test/resources/scripts/Say_Hello.groovy create mode 100644 scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java create mode 100644 scijava-javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy create mode 100644 scijava-javafx/src/test/resources/scripts/Say_Hello.groovy create mode 100644 scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java create mode 100644 scijava-script3/src/test/java/org/scijava/script3/ScriptFinderTest.java create mode 100644 scijava-script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy create mode 100644 scijava-script3/src/test/resources/scripts/Say_Hello.groovy create mode 100644 scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java create mode 100644 scijava-swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy create mode 100644 scijava-swing/src/test/resources/scripts/Say_Hello.groovy diff --git a/docs/migration-plan.md b/docs/migration-plan.md index ec58c5709..93b52e8fb 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -835,6 +835,32 @@ widgets and watching its callbacks fire. 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. +- **`ExecutableInfo` is how an application takes more than one source.** A + `CommandInfo` is one; `ExecutableInfo.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 diff --git a/docs/migration.md b/docs/migration.md index 70bedd731..b924e63df 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -123,12 +123,12 @@ A **dropped** row is a successful outcome, not a gap. | `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 | | `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.command`: `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`; `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 | 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.command`: `MenuTree` is the model and `MenuCreator`/`Menus` the toolkit-free walk, with `SwingMenus` the first implementation. `MenuService` has no equivalent: a menu bar is `MenuTree.of(Commands.discover(context))`, which is a line rather than a service | ported | +| `menu` (`MenuCreator`, `AbstractMenuCreator`, `MenuService`, `ShadowMenu`) | `org.scijava.command`: `ExecutableInfo` is anything an application 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`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/scijava-awt/pom.xml b/scijava-awt/pom.xml index 097d64637..c80720c84 100644 --- a/scijava-awt/pom.xml +++ b/scijava-awt/pom.xml @@ -92,6 +92,7 @@ 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 @@ -158,6 +159,23 @@
+ + + org.scijava + scijava-script3 + ${scijava-script3.version} + test + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + org.scijava scijava-ui3-test diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java index 4a7b4f624..e9528bb07 100644 --- a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java +++ b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java @@ -38,7 +38,7 @@ import java.util.function.Consumer; import org.scijava.command.Accelerator; -import org.scijava.command.CommandInfo; +import org.scijava.command.ExecutableInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; import org.scijava.command.Menus; @@ -54,15 +54,15 @@ public class AwtMenus implements MenuCreator { private static final Logger log = LoggerFactory.getLogger(AwtMenus.class); - private final Consumer onSelect; + private final Consumer onSelect; - public AwtMenus(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) + final Consumer onSelect) { return Menus.build(root, new MenuBar(), new AwtMenus(onSelect)); } @@ -83,7 +83,7 @@ public Menu subMenu(final MenuTree node, final Menu parent) { @Override public void item(final MenuTree leaf, final Menu parent) { - final CommandInfo command = leaf.command().orElseThrow(); + final ExecutableInfo 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. diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java index 6dbadd312..7e8c04d1e 100644 --- a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java +++ b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java @@ -42,8 +42,8 @@ import java.util.concurrent.Future; import java.util.stream.Collectors; -import org.scijava.command.CommandInfo; import org.scijava.command.Commands; +import org.scijava.command.ExecutableInfo; import org.scijava.command.MenuTree; import org.scijava.context.Context; import org.scijava.execute.ExecutionResult; @@ -72,8 +72,7 @@ public class AwtShell { private final Context context = Context.create(); - private final java.util.List commands = Commands.discover( - context); + private final java.util.List commands = entries(); private final Runner runner; private final Frame frame = new Frame("SciJava"); @@ -81,7 +80,7 @@ public class AwtShell { private final TextField search = new TextField(24); private final List matches = new List(8); - private java.util.List shown = java.util.List.of(); + private java.util.List shown = java.util.List.of(); public AwtShell() { runner = Runner.of(java.util.List.of(AwtInputHarvester.of(context)), @@ -118,6 +117,24 @@ public static void main(final String... args) { // -- 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(ExecutableInfo.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); @@ -136,16 +153,16 @@ private void runSelected() { } /** Runs a command, and says what came back. */ - private void run(final CommandInfo command) { + private void run(final ExecutableInfo 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.className()).start(); + }, "shell-" + command.name()).start(); } - private static String describe(final CommandInfo command) { + private static String describe(final ExecutableInfo command) { return command.menuPath().map(path -> path.replace(">", " ▸ ")) // .orElseGet(() -> command.label() + " (not in any menu)"); } diff --git a/scijava-awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/scijava-awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/scijava-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/scijava-awt/src/test/resources/scripts/Say_Hello.groovy b/scijava-awt/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/scijava-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/scijava-command/src/main/java/org/scijava/command/CommandInfo.java b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java index 70d21d51d..cf908bca0 100644 --- a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java +++ b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java @@ -57,7 +57,7 @@ * * @author Curtis Rueden */ -public class CommandInfo implements Executable { +public class CommandInfo implements ExecutableInfo { private final Discovery discovery; private final Map menu; @@ -78,11 +78,13 @@ public String className() { } /** 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)) // @@ -90,22 +92,26 @@ public String label() { } /** 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")); } diff --git a/scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java b/scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java new file mode 100644 index 000000000..9ada77e8f --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java @@ -0,0 +1,182 @@ +/* + * #%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.command; + +import java.util.Map; +import java.util.Optional; + +import org.scijava.execute.Executable; +import org.scijava.execute.ExecutableInstance; +import org.scijava.struct.Struct; + +/** + * Something an application can run, and what it needs to present it. + *

+ * A {@link CommandInfo} is one, read from the annotation index. So is a + * script, whose {@code #@script} directives say the same things; so, in time, + * is a SciJava Common {@code ModuleInfo} presented through the legacy bridge. + * An application therefore gathers what it can run from as many sources as it + * has, and nothing downstream learns there was more than one. + *

+ *

+ * 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 ExecutableInfo 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 + * @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 ExecutableInfo of(final Executable executable, + final Map attrs) + { + return new ExecutableInfo() { + + @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/scijava-command/src/main/java/org/scijava/command/MenuTree.java b/scijava-command/src/main/java/org/scijava/command/MenuTree.java index 604205299..01164a4f8 100644 --- a/scijava-command/src/main/java/org/scijava/command/MenuTree.java +++ b/scijava-command/src/main/java/org/scijava/command/MenuTree.java @@ -64,10 +64,10 @@ public class MenuTree { public static final String SEPARATOR = ">"; private final String label; - private final CommandInfo command; + private final ExecutableInfo command; private final Map children = new LinkedHashMap<>(); - private MenuTree(final String label, final CommandInfo command) { + private MenuTree(final String label, final ExecutableInfo command) { this.label = label; this.command = command; } @@ -75,16 +75,19 @@ private MenuTree(final String label, final CommandInfo command) { /** * Builds a menu tree from the given commands. *

- * Commands with no menu path, and those marked not visible, are left out: - * they remain perfectly runnable, they simply do not appear. + * 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 commands to arrange + * @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) { + public static MenuTree of(final Collection commands) { final MenuTree root = new MenuTree("", null); - for (final CommandInfo command : commands) { + for (final ExecutableInfo command : commands) { if (!command.isVisible()) continue; final Optional path = command.menuPath(); if (path.isEmpty()) continue; @@ -99,8 +102,8 @@ public String label() { return label; } - /** Gets the command this entry runs, if it is a leaf. */ - public Optional command() { + /** Gets what this entry runs, if it is a leaf. */ + public Optional entry() { return Optional.ofNullable(command); } @@ -135,8 +138,8 @@ public Optional find(final String path) { } /** Gets every command in this subtree, in display order. */ - public List leaves() { - final List leaves = new ArrayList<>(); + public List leaves() { + final List leaves = new ArrayList<>(); collectLeaves(leaves); return leaves; } @@ -148,11 +151,11 @@ public String toString() { // -- Helper methods -- - private void add(final CommandInfo command, final String[] path) { + private void add(final ExecutableInfo command, final String[] path) { add(command, path, 0); } - private void add(final CommandInfo command, final String[] path, + private void add(final ExecutableInfo command, final String[] path, final int depth) { final String element = path[depth].trim(); @@ -196,7 +199,7 @@ private double weight() { .min().orElse(Double.POSITIVE_INFINITY); } - private void collectLeaves(final List leaves) { + private void collectLeaves(final List leaves) { if (isLeaf()) { leaves.add(command); return; diff --git a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java index ede69e6cb..17af7d84a 100644 --- a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java +++ b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java @@ -31,6 +31,7 @@ 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; @@ -192,4 +193,29 @@ private static boolean constructed(final String className) { 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 ExecutableInfo} 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 ExecutableInfo 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/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java b/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java index e6f1a5b8f..fc3f8d22f 100644 --- a/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java +++ b/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java @@ -65,8 +65,8 @@ public void testTreeStructure() { final MenuTree leaf = root.find("Image>Adjust>Brightness/Contrast...") .orElseThrow(); assertTrue(leaf.isLeaf()); - assertEquals(DeepCommand.class.getName(), leaf.command().orElseThrow() - .className()); + assertEquals(DeepCommand.class.getName(), leaf.entry().orElseThrow() + .name()); } } @@ -106,7 +106,7 @@ 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::className) // + .map(ExecutableInfo::name) // .collect(Collectors.toList()); assertFalse(classNames.contains(Headless.class.getName())); } diff --git a/scijava-javafx/pom.xml b/scijava-javafx/pom.xml index 822eba8a9..809d2e1ee 100644 --- a/scijava-javafx/pom.xml +++ b/scijava-javafx/pom.xml @@ -95,6 +95,7 @@ 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 @@ -176,6 +177,23 @@
+ + + org.scijava + scijava-script3 + ${scijava-script3.version} + test + + + org.apache.groovy + groovy + test + + + org.apache.groovy + groovy-jsr223 + test + org.scijava scijava-ui3-test diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java index 0c3ae48ea..678503820 100644 --- a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java @@ -44,7 +44,7 @@ import javafx.scene.input.KeyCombination.Modifier; import org.scijava.command.Accelerator; -import org.scijava.command.CommandInfo; +import org.scijava.command.ExecutableInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; import org.scijava.command.Menus; @@ -60,15 +60,15 @@ public class FxMenus implements MenuCreator { private static final Logger log = LoggerFactory.getLogger(FxMenus.class); - private final Consumer onSelect; + private final Consumer onSelect; - public FxMenus(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) + final Consumer onSelect) { return Menus.build(root, new MenuBar(), new FxMenus(onSelect)); } @@ -89,7 +89,7 @@ public Menu subMenu(final MenuTree node, final Menu parent) { @Override public void item(final MenuTree leaf, final Menu parent) { - final CommandInfo command = leaf.command().orElseThrow(); + final ExecutableInfo command = leaf.entry().orElseThrow(); final MenuItem item = new MenuItem(leaf.label()); command.accelerator().map(FxMenus::accelerator).ifPresent( item::setAccelerator); diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java index 1e76e6955..4fabdad39 100644 --- a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java @@ -50,8 +50,8 @@ import javafx.scene.layout.VBox; import javafx.stage.Stage; -import org.scijava.command.CommandInfo; import org.scijava.command.Commands; +import org.scijava.command.ExecutableInfo; import org.scijava.command.MenuTree; import org.scijava.context.Context; import org.scijava.execute.ExecutionResult; @@ -80,14 +80,14 @@ public class FxShell { private final Context context = Context.create(); - private final List commands = Commands.discover(context); + 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<>(); + private final ListView matches = new ListView<>(); public FxShell() { runner = Runner.of(List.of(FxInputHarvester.of(context)), List.of()); @@ -101,7 +101,7 @@ public FxShell() { matches.setCellFactory(list -> new ListCell<>() { @Override - protected void updateItem(final CommandInfo item, final boolean empty) { + protected void updateItem(final ExecutableInfo item, final boolean empty) { super.updateItem(item, empty); setText(empty || item == null ? null : describe(item)); } @@ -125,6 +125,24 @@ public static void main(final String... args) { // -- 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(ExecutableInfo.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)); @@ -144,7 +162,7 @@ private VBox statusPane() { /** 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() // + final List found = commands.stream() // .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // .collect(Collectors.toList()); matches.setItems(FXCollections.observableArrayList(found)); @@ -153,12 +171,12 @@ private void filter() { } private void runSelected() { - final CommandInfo command = matches.getSelectionModel().getSelectedItem(); + final ExecutableInfo command = matches.getSelectionModel().getSelectedItem(); if (command != null) run(command); } /** Runs a command, and says what came back. */ - private void run(final CommandInfo command) { + private void run(final ExecutableInfo command) { status.setText("Running " + command.label() + "..."); progress.setVisible(true); final Future future = runner.run(command, Map.of()); @@ -168,10 +186,10 @@ private void run(final CommandInfo command) { progress.setVisible(false); status.setText(message); }); - }, "shell-" + command.className()).start(); + }, "shell-" + command.name()).start(); } - private static String describe(final CommandInfo command) { + private static String describe(final ExecutableInfo command) { return command.menuPath().map(path -> path.replace(">", " ▸ ")) // .orElseGet(() -> command.label() + " (not in any menu)"); } diff --git a/scijava-javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/scijava-javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/scijava-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/scijava-javafx/src/test/resources/scripts/Say_Hello.groovy b/scijava-javafx/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/scijava-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-script3/src/main/java/org/scijava/script3/ScriptFinder.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java new file mode 100644 index 000000000..77f112626 --- /dev/null +++ b/scijava-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 ExecutableInfo.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/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java index 94e7a10c2..397242a98 100644 --- a/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java +++ b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java @@ -33,6 +33,7 @@ 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; @@ -111,7 +112,7 @@ public String toString() { 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 SHEBANG = Pattern.compile("^#!(.*)$"); private static final Pattern DIRECTION = Pattern.compile( "^(input|output|both)\\b(.*)$"); @@ -121,6 +122,7 @@ public String toString() { private final List parameters = new ArrayList<>(); private final Map directives = new LinkedHashMap<>(); + private String shebang; private String body; /** Reads what the given script declares. */ @@ -144,7 +146,19 @@ public static ScriptHeader parse(final String code) { // 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. - if (declaration || SHEBANG.matcher(line).matches()) lines[i] = ""; + 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; @@ -163,11 +177,36 @@ public List parameters() { return List.copyOf(parameters); } - /** Gets the script's own metadata: its menu path, its label. */ + /** + * 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.command.ExecutableInfo.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) { diff --git a/scijava-script3/src/main/java/org/scijava/script3/Scripts.java b/scijava-script3/src/main/java/org/scijava/script3/Scripts.java index 26cd9c072..f70c844c2 100644 --- a/scijava-script3/src/main/java/org/scijava/script3/Scripts.java +++ b/scijava-script3/src/main/java/org/scijava/script3/Scripts.java @@ -106,19 +106,53 @@ public Optional language(final String nameOrExtension) { */ public ScriptExecutable of(final Path path) { final String fileName = path.getFileName().toString(); - final int dot = fileName.lastIndexOf('.'); - final String extension = dot < 0 ? "" : fileName.substring(dot + 1); - final ScriptLanguage language = language(extension).orElseThrow( - () -> new ScriptException("No language for '." + extension + - "'. Available: " + names())); + final String code; try { - final String code = new String(Files.readAllBytes(path), - StandardCharsets.UTF_8); - return new ScriptExecutable(fileName, code, language); + 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())); } /** @@ -133,9 +167,15 @@ public ScriptExecutable of(final Path path) { public ScriptExecutable of(final String name, final String code, final String nameOrExtension) { - final ScriptLanguage language = language(nameOrExtension).orElseThrow( + return of(name, code, language(nameOrExtension).orElseThrow( () -> new ScriptException("No such language: " + nameOrExtension + - ". Available: " + names())); + ". 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); } diff --git a/scijava-script3/src/test/java/org/scijava/script3/ScriptFinderTest.java b/scijava-script3/src/test/java/org/scijava/script3/ScriptFinderTest.java new file mode 100644 index 000000000..9fc507b2a --- /dev/null +++ b/scijava-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/scijava-script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/scijava-script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/scijava-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/scijava-script3/src/test/resources/scripts/Say_Hello.groovy b/scijava-script3/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/scijava-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/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java index 59ae93ab8..71b55fd4b 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java @@ -43,7 +43,7 @@ import javax.swing.KeyStroke; import org.scijava.command.Accelerator; -import org.scijava.command.CommandInfo; +import org.scijava.command.ExecutableInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; import org.scijava.command.Menus; @@ -64,20 +64,20 @@ public class SwingMenus implements MenuCreator { private static final Logger log = LoggerFactory.getLogger(SwingMenus.class); - private final Consumer onSelect; + 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) { + 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) + final Consumer onSelect) { return Menus.build(root, new JMenuBar(), new SwingMenus(onSelect)); } @@ -98,7 +98,7 @@ public JMenu subMenu(final MenuTree node, final JMenu parent) { @Override public void item(final MenuTree leaf, final JMenu parent) { - final CommandInfo command = leaf.command().orElseThrow(); + final ExecutableInfo command = leaf.entry().orElseThrow(); final JMenuItem item = new JMenuItem(leaf.label()); command.accelerator().map(SwingMenus::keyStroke).ifPresent( item::setAccelerator); diff --git a/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java b/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java new file mode 100644 index 000000000..8d3ee1c69 --- /dev/null +++ b/scijava-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.command.ExecutableInfo; +import org.scijava.command.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 ExecutableInfo 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.command.Commands.discover(context).stream() // + .filter(c -> c.className().contains("SampleCommands")) // + .forEach(entries::add); + } + new ScriptFinder().find(scripts()).forEach(found -> entries.add(ExecutableInfo + .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/scijava-swing/src/test/java/org/scijava/swing/Shell.java b/scijava-swing/src/test/java/org/scijava/swing/Shell.java index d926660ce..f8742981e 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/Shell.java +++ b/scijava-swing/src/test/java/org/scijava/swing/Shell.java @@ -32,7 +32,13 @@ 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; @@ -51,12 +57,13 @@ import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import org.scijava.command.CommandInfo; import org.scijava.command.Commands; +import org.scijava.command.ExecutableInfo; import org.scijava.command.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. @@ -70,7 +77,9 @@ * *

* It is the first thing to put the whole stack together: commands declared in - * {@code SampleCommands} and found through the annotation index, arranged by + * {@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 ExecutableInfo} - 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 @@ -82,16 +91,16 @@ public class Shell { private final Context context = Context.create(); - private final List commands = Commands.discover(context); + 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 = + private final DefaultListModel matchModel = new DefaultListModel<>(); - private final JList matches = new JList<>(matchModel); + private final JList matches = new JList<>(matchModel); public Shell() { final SwingInputHarvester harvester = SwingInputHarvester.of(context); @@ -111,6 +120,35 @@ public static void main(final String... args) { // -- 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 ExecutableInfo}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(ExecutableInfo.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) -> { @@ -186,7 +224,7 @@ public void mouseClicked(final java.awt.event.MouseEvent e) { /** 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() // + final List found = commands.stream() // .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // .collect(Collectors.toList()); matchModel.clear(); @@ -196,12 +234,12 @@ private void filter() { } private void runSelected() { - final CommandInfo command = matches.getSelectedValue(); + final ExecutableInfo command = matches.getSelectedValue(); if (command != null) run(command); } /** Runs a command, and says what came back. */ - private void run(final CommandInfo command) { + private void run(final ExecutableInfo command) { status.setText("Running " + command.label() + "..."); progress.setIndeterminate(true); progress.setVisible(true); @@ -213,10 +251,10 @@ private void run(final CommandInfo command) { progress.setIndeterminate(false); status.setText(message); }); - }, "shell-" + command.className()).start(); + }, "shell-" + command.name()).start(); } - private static String describe(final CommandInfo command) { + private static String describe(final ExecutableInfo command) { return command.menuPath().map(path -> path.replace(">", " ▸ ")) // .orElseGet(() -> command.label() + " (not in any menu)"); } diff --git a/scijava-swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/scijava-swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy new file mode 100644 index 000000000..ace8fc283 --- /dev/null +++ b/scijava-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/scijava-swing/src/test/resources/scripts/Say_Hello.groovy b/scijava-swing/src/test/resources/scripts/Say_Hello.groovy new file mode 100644 index 000000000..c03de30a6 --- /dev/null +++ b/scijava-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 From 7c100d448245f9e3f00e223f2fa3cd0461ec2fa8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 21:57:39 -0500 Subject: [PATCH 45/59] Name the general thing what users call it: CommandInfo A command is anything a user can run, with declared parameters -- whether its implementation is a class or a script. So that is what `CommandInfo` means now, and the class-based kind is `ClassCommandInfo`. The old names said the opposite. `Command` claimed in its javadoc that a script is a command whose implementation is source in some language, while the type system had `Command` meaning the Java-class kind specifically and scripts attaching one level lower, at `Executable`. The javadoc was the intent; the types were what got built when scripts arrived after the command layer. This reconciles them in the direction of the intent. Not `JavaCommandInfo`: Kotlin, Groovy and Scala classes implement `Command` just as well, so the distinction is class versus script, not Java versus everything. And `ClassCommandInfo` rather than `CommandClassInfo`, so that the family reads as a series -- the siblings coming are `ScriptCommandInfo` and `LegacyCommandInfo`. The alternative was to believe the types instead: call the general thing `ExecutableInfo` and let "command" keep meaning the class kind. That is SciJava Common's design with "executable" substituted for "module" -- one word users do not say traded for another. `Command` the interface is now documented as what it is: the way to write a command as a class, not the definition of one. A script implements nothing at all and is no less a command for it. docs/migration-plan.md gains an "On the words" table, so the vocabulary stops drifting: command, class command, script command, executable, module (JPMS only), plugin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 30 ++- docs/migration.md | 4 +- .../main/java/org/scijava/awt/AwtMenus.java | 10 +- .../test/java/org/scijava/awt/AwtShell.java | 16 +- .../org/scijava/command/ClassCommandInfo.java | 176 +++++++++++++ .../java/org/scijava/command/Command.java | 13 +- .../java/org/scijava/command/CommandInfo.java | 236 ++++++++++-------- .../java/org/scijava/command/Commands.java | 10 +- .../org/scijava/command/ExecutableInfo.java | 182 -------------- .../java/org/scijava/command/MenuTree.java | 20 +- .../org/scijava/command/CommandsTest.java | 20 +- .../org/scijava/command/MenuTreeTest.java | 2 +- .../main/java/org/scijava/javafx/FxMenus.java | 10 +- .../test/java/org/scijava/javafx/FxShell.java | 22 +- .../org/scijava/script3/ScriptFinder.java | 2 +- .../org/scijava/script3/ScriptHeader.java | 2 +- .../java/org/scijava/swing/SwingMenus.java | 10 +- .../org/scijava/swing/ScriptMenuTest.java | 10 +- .../test/java/org/scijava/swing/Shell.java | 26 +- .../org/scijava/ui3/test/MenuConformance.java | 6 +- 20 files changed, 426 insertions(+), 381 deletions(-) create mode 100644 scijava-command/src/main/java/org/scijava/command/ClassCommandInfo.java delete mode 100644 scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 93b52e8fb..89c151b3b 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -758,6 +758,32 @@ ways, dependency flowing one way. That is what makes the migration incremental rather than a big bang, and it should be sequenced *with* layer 1 rather than after it. +### 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 @@ -846,8 +872,8 @@ widgets and watching its callbacks fire. 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. -- **`ExecutableInfo` is how an application takes more than one source.** A - `CommandInfo` is one; `ExecutableInfo.of(executable, attrs)` makes one out +- **`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. diff --git a/docs/migration.md b/docs/migration.md index b924e63df..78654ae86 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -122,13 +122,13 @@ A **dropped** row is a successful outcome, not a gap. | `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 | | `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.command`: `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`; `MenuTree` replaces `ShadowMenu`. There is no `DynamicCommand`: see `choicesFrom` and `@Group(membersFrom)` | ported | +| `command` | `org.scijava.command`: `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.command`: `ExecutableInfo` is anything an application 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 | +| `menu` (`MenuCreator`, `AbstractMenuCreator`, `MenuService`, `ShadowMenu`) | `org.scijava.command`: `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`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java index e9528bb07..c9949aec9 100644 --- a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java +++ b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java @@ -38,7 +38,7 @@ import java.util.function.Consumer; import org.scijava.command.Accelerator; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; import org.scijava.command.Menus; @@ -54,15 +54,15 @@ public class AwtMenus implements MenuCreator { private static final Logger log = LoggerFactory.getLogger(AwtMenus.class); - private final Consumer onSelect; + private final Consumer onSelect; - public AwtMenus(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) + final Consumer onSelect) { return Menus.build(root, new MenuBar(), new AwtMenus(onSelect)); } @@ -83,7 +83,7 @@ public Menu subMenu(final MenuTree node, final Menu parent) { @Override public void item(final MenuTree leaf, final Menu parent) { - final ExecutableInfo command = leaf.entry().orElseThrow(); + 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. diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java index 7e8c04d1e..98d5fb0a7 100644 --- a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java +++ b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java @@ -43,7 +43,7 @@ import java.util.stream.Collectors; import org.scijava.command.Commands; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuTree; import org.scijava.context.Context; import org.scijava.execute.ExecutionResult; @@ -72,7 +72,7 @@ public class AwtShell { private final Context context = Context.create(); - private final java.util.List commands = entries(); + private final java.util.List commands = entries(); private final Runner runner; private final Frame frame = new Frame("SciJava"); @@ -80,7 +80,7 @@ public class AwtShell { private final TextField search = new TextField(24); private final List matches = new List(8); - private java.util.List shown = java.util.List.of(); + private java.util.List shown = java.util.List.of(); public AwtShell() { runner = Runner.of(java.util.List.of(AwtInputHarvester.of(context)), @@ -118,14 +118,14 @@ public static void main(final String... args) { // -- 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<>( + 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(ExecutableInfo.of(found.script(), + .toURI())).forEach(found -> entries.add(CommandInfo.of(found.script(), found.metadata()))); } catch (final java.net.URISyntaxException exc) { @@ -153,7 +153,7 @@ private void runSelected() { } /** Runs a command, and says what came back. */ - private void run(final ExecutableInfo command) { + private void run(final CommandInfo command) { status.setText("Running " + command.label() + "..."); final Future future = runner.run(command, Map.of()); new Thread(() -> { @@ -162,7 +162,7 @@ private void run(final ExecutableInfo command) { }, "shell-" + command.name()).start(); } - private static String describe(final ExecutableInfo command) { + private static String describe(final CommandInfo command) { return command.menuPath().map(path -> path.replace(">", " ▸ ")) // .orElseGet(() -> command.label() + " (not in any menu)"); } diff --git a/scijava-command/src/main/java/org/scijava/command/ClassCommandInfo.java b/scijava-command/src/main/java/org/scijava/command/ClassCommandInfo.java new file mode 100644 index 000000000..1e97bbbd3 --- /dev/null +++ b/scijava-command/src/main/java/org/scijava/command/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.command; + +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/scijava-command/src/main/java/org/scijava/command/Command.java b/scijava-command/src/main/java/org/scijava/command/Command.java index 6816f2fb5..93a8545f1 100644 --- a/scijava-command/src/main/java/org/scijava/command/Command.java +++ b/scijava-command/src/main/java/org/scijava/command/Command.java @@ -52,12 +52,15 @@ *

On the word

*

* "Command" here means what SciJava Common called a module: anything - * runnable with declared parameters. A script is a command whose + * 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 Java. Neither is a special case of the - * other, and both reach the menu, the harvester and the runner by one path - - * see {@code org.scijava.execute.Executable}, which mentions no Java - * reflection at all. + * 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, diff --git a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java index cf908bca0..6eac3aee1 100644 --- a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java +++ b/scijava-command/src/main/java/org/scijava/command/CommandInfo.java @@ -32,139 +32,161 @@ 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. + * A command: something a user can run, and what is needed to present it. *

- * 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. + * 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 {@code ModuleInfo}, and its - * {@code CommandInfo} 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: 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 class CommandInfo implements ExecutableInfo { - - private final Discovery discovery; - private final Map menu; - private Executable delegate; - - CommandInfo(final Discovery discovery, - final Map menu) - { - this.discovery = discovery; - this.menu = menu; - } +public interface CommandInfo extends Executable { - // -- 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); - } + /** + * 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(); - // -- Executable methods -- + /** Gets the label to display. */ + String label(); - @Override - public String name() { - return className(); + /** Gets the keyboard shortcut, if there is one. */ + default Optional accelerator() { + return Optional.empty(); } - @Override - public Struct struct() { - return delegate().struct(); + /** Gets the icon resource, if there is one. */ + default Optional iconPath() { + return Optional.empty(); } - @Override - public ExecutableInstance create() { - return delegate().create(); + /** Gets how this sorts among its menu siblings; lower comes first. */ + default double weight() { + return Double.POSITIVE_INFINITY; } - @Override - public String toString() { - return className() + menuPath().map(p -> " [" + p + "]").orElse(""); + /** Gets whether this should appear in menus at all. */ + default boolean isVisible() { + return true; } /** - * Gets the executable behind this command, loading the class on first use. + * Describes anything runnable, from metadata written the way a + * {@code @Menu} annotation writes it. *

- * NB: everything above this line answers from the index alone. This is - * where a menu stops being free. + * 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 */ - 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; + 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/scijava-command/src/main/java/org/scijava/command/Commands.java b/scijava-command/src/main/java/org/scijava/command/Commands.java index fe271a2a8..71fd76b94 100644 --- a/scijava-command/src/main/java/org/scijava/command/Commands.java +++ b/scijava-command/src/main/java/org/scijava/command/Commands.java @@ -64,17 +64,17 @@ private Commands() { * @param context the application context * @return the commands, ordered by menu weight and then by label */ - public static List discover(final Context context) { + public static List discover(final Context context) { final Map> menus = menusByClassName( // Thread.currentThread().getContextClassLoader()); - final List commands = new ArrayList<>(); + final List commands = new ArrayList<>(); for (final Discovery discovery : context.plugins(Command.class)) { - commands.add(new CommandInfo(discovery, menus.getOrDefault( // + commands.add(new ClassCommandInfo(discovery, menus.getOrDefault( // discovery.implClassName(), Map.of()))); } - commands.sort(Comparator.comparingDouble(CommandInfo::weight) // - .thenComparing(CommandInfo::label)); + commands.sort(Comparator.comparingDouble(ClassCommandInfo::weight) // + .thenComparing(ClassCommandInfo::label)); return commands; } diff --git a/scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java b/scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java deleted file mode 100644 index 9ada77e8f..000000000 --- a/scijava-command/src/main/java/org/scijava/command/ExecutableInfo.java +++ /dev/null @@ -1,182 +0,0 @@ -/* - * #%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.command; - -import java.util.Map; -import java.util.Optional; - -import org.scijava.execute.Executable; -import org.scijava.execute.ExecutableInstance; -import org.scijava.struct.Struct; - -/** - * Something an application can run, and what it needs to present it. - *

- * A {@link CommandInfo} is one, read from the annotation index. So is a - * script, whose {@code #@script} directives say the same things; so, in time, - * is a SciJava Common {@code ModuleInfo} presented through the legacy bridge. - * An application therefore gathers what it can run from as many sources as it - * has, and nothing downstream learns there was more than one. - *

- *

- * 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 ExecutableInfo 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 - * @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 ExecutableInfo of(final Executable executable, - final Map attrs) - { - return new ExecutableInfo() { - - @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/scijava-command/src/main/java/org/scijava/command/MenuTree.java b/scijava-command/src/main/java/org/scijava/command/MenuTree.java index 01164a4f8..cece7b47d 100644 --- a/scijava-command/src/main/java/org/scijava/command/MenuTree.java +++ b/scijava-command/src/main/java/org/scijava/command/MenuTree.java @@ -64,10 +64,10 @@ public class MenuTree { public static final String SEPARATOR = ">"; private final String label; - private final ExecutableInfo command; + private final CommandInfo command; private final Map children = new LinkedHashMap<>(); - private MenuTree(final String label, final ExecutableInfo command) { + private MenuTree(final String label, final CommandInfo command) { this.label = label; this.command = command; } @@ -85,9 +85,9 @@ private MenuTree(final String label, final ExecutableInfo command) { * 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) { + public static MenuTree of(final Collection commands) { final MenuTree root = new MenuTree("", null); - for (final ExecutableInfo command : commands) { + for (final CommandInfo command : commands) { if (!command.isVisible()) continue; final Optional path = command.menuPath(); if (path.isEmpty()) continue; @@ -103,7 +103,7 @@ public String label() { } /** Gets what this entry runs, if it is a leaf. */ - public Optional entry() { + public Optional entry() { return Optional.ofNullable(command); } @@ -138,8 +138,8 @@ public Optional find(final String path) { } /** Gets every command in this subtree, in display order. */ - public List leaves() { - final List leaves = new ArrayList<>(); + public List leaves() { + final List leaves = new ArrayList<>(); collectLeaves(leaves); return leaves; } @@ -151,11 +151,11 @@ public String toString() { // -- Helper methods -- - private void add(final ExecutableInfo command, final String[] path) { + private void add(final CommandInfo command, final String[] path) { add(command, path, 0); } - private void add(final ExecutableInfo command, final String[] path, + private void add(final CommandInfo command, final String[] path, final int depth) { final String element = path[depth].trim(); @@ -199,7 +199,7 @@ private double weight() { .min().orElse(Double.POSITIVE_INFINITY); } - private void collectLeaves(final List leaves) { + private void collectLeaves(final List leaves) { if (isLeaf()) { leaves.add(command); return; diff --git a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java index 17af7d84a..cf2414fa0 100644 --- a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java +++ b/scijava-command/src/test/java/org/scijava/command/CommandsTest.java @@ -56,7 +56,7 @@ public class CommandsTest { public void testDiscovery() { try (final Context context = Context.create()) { final List names = Commands.discover(context).stream() // - .map(CommandInfo::className) // + .map(ClassCommandInfo::className) // .collect(Collectors.toList()); assertTrue(names.contains(SayHello.class.getName()), names.toString()); assertTrue(names.contains(CountBeans.class.getName()), names.toString()); @@ -71,7 +71,7 @@ public void testDiscovery() { @Test public void testMenuMetadataWithoutLoadingCommands() { try (final Context context = Context.create()) { - final CommandInfo hello = find(context, SayHello.class); + final ClassCommandInfo hello = find(context, SayHello.class); assertEquals("Help>Say Hello...", hello.menuPath().orElse(null)); assertEquals("Say Hello...", hello.label()); @@ -83,7 +83,7 @@ public void testMenuMetadataWithoutLoadingCommands() { // 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 CommandInfo never = find(context, NeverRun.class); + final ClassCommandInfo never = find(context, NeverRun.class); assertEquals("Help>Never Run", never.menuPath().orElse(null)); assertFalse(constructed("org.scijava.command.NeverRun"), "building a menu must not construct commands"); @@ -100,7 +100,7 @@ public void testMenuOrdering() { final List ofInterest = List.of("Count Beans", "Say Hello...", "Never Run"); final List labels = Commands.discover(context).stream() // - .map(CommandInfo::label) // + .map(ClassCommandInfo::label) // .filter(ofInterest::contains) // .collect(Collectors.toList()); // Weights: CountBeans 3, SayHello 12, NeverRun 50. @@ -112,7 +112,7 @@ public void testMenuOrdering() { @Test public void testCommandWithoutAMenuEntry() { try (final Context context = Context.create()) { - final CommandInfo headless = find(context, Headless.class); + 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()); @@ -123,7 +123,7 @@ public void testCommandWithoutAMenuEntry() { @Test public void testRunADiscoveredCommand() throws Exception { try (final Context context = Context.create()) { - final CommandInfo hello = find(context, SayHello.class); + 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")) @@ -140,7 +140,7 @@ public void testRunADiscoveredCommand() throws Exception { @Test public void testEachRunHasItsOwnInstance() throws Exception { try (final Context context = Context.create()) { - final CommandInfo beans = find(context, CountBeans.class); + 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, @@ -175,7 +175,7 @@ public void testOneOpensIsEnough() { "commands should not need opening to the execution layer as well"); } - private static CommandInfo find(final Context context, final Class type) { + private static ClassCommandInfo find(final Context context, final Class type) { return Commands.discover(context).stream() // .filter(c -> type.getName().equals(c.className())) // .findFirst().orElseThrow(); @@ -197,7 +197,7 @@ private static boolean constructed(final String className) { /** * Something runnable need not be in a menu, and is no less runnable for it. *

- * NB: this is why the type is {@code ExecutableInfo} rather than + * 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. @@ -206,7 +206,7 @@ private static boolean constructed(final String className) { @Test public void testRunnableWithoutAMenuPath() { try (final Context context = Context.create()) { - final ExecutableInfo hidden = Commands.discover(context).stream() // + final CommandInfo hidden = Commands.discover(context).stream() // .filter(c -> c.className().equals(Headless.class.getName())) // .findFirst().orElseThrow(); diff --git a/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java b/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java index fc3f8d22f..8f0e4022b 100644 --- a/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java +++ b/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java @@ -106,7 +106,7 @@ public void testCommandWithoutMenuPathIsNotInTheTree() { try (final Context context = Context.create()) { final MenuTree root = MenuTree.of(Commands.discover(context)); final List classNames = root.leaves().stream() // - .map(ExecutableInfo::name) // + .map(CommandInfo::name) // .collect(Collectors.toList()); assertFalse(classNames.contains(Headless.class.getName())); } diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java index 678503820..c461b2f6d 100644 --- a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java @@ -44,7 +44,7 @@ import javafx.scene.input.KeyCombination.Modifier; import org.scijava.command.Accelerator; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; import org.scijava.command.Menus; @@ -60,15 +60,15 @@ public class FxMenus implements MenuCreator { private static final Logger log = LoggerFactory.getLogger(FxMenus.class); - private final Consumer onSelect; + private final Consumer onSelect; - public FxMenus(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) + final Consumer onSelect) { return Menus.build(root, new MenuBar(), new FxMenus(onSelect)); } @@ -89,7 +89,7 @@ public Menu subMenu(final MenuTree node, final Menu parent) { @Override public void item(final MenuTree leaf, final Menu parent) { - final ExecutableInfo command = leaf.entry().orElseThrow(); + final CommandInfo command = leaf.entry().orElseThrow(); final MenuItem item = new MenuItem(leaf.label()); command.accelerator().map(FxMenus::accelerator).ifPresent( item::setAccelerator); diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java index 4fabdad39..38c80c198 100644 --- a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java @@ -51,7 +51,7 @@ import javafx.stage.Stage; import org.scijava.command.Commands; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuTree; import org.scijava.context.Context; import org.scijava.execute.ExecutionResult; @@ -80,14 +80,14 @@ public class FxShell { private final Context context = Context.create(); - private final List commands = entries(); + 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<>(); + private final ListView matches = new ListView<>(); public FxShell() { runner = Runner.of(List.of(FxInputHarvester.of(context)), List.of()); @@ -101,7 +101,7 @@ public FxShell() { matches.setCellFactory(list -> new ListCell<>() { @Override - protected void updateItem(final ExecutableInfo item, final boolean empty) { + protected void updateItem(final CommandInfo item, final boolean empty) { super.updateItem(item, empty); setText(empty || item == null ? null : describe(item)); } @@ -126,14 +126,14 @@ public static void main(final String... args) { // -- Helper methods -- /** Gathers what this application can run: commands, and scripts. */ - private List entries() { - final List entries = new java.util.ArrayList<>(Commands.discover( + 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(ExecutableInfo.of(found.script(), + .toURI())).forEach(found -> entries.add(CommandInfo.of(found.script(), found.metadata()))); } catch (final java.net.URISyntaxException exc) { @@ -162,7 +162,7 @@ private VBox statusPane() { /** 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() // + final List found = commands.stream() // .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // .collect(Collectors.toList()); matches.setItems(FXCollections.observableArrayList(found)); @@ -171,12 +171,12 @@ private void filter() { } private void runSelected() { - final ExecutableInfo command = matches.getSelectionModel().getSelectedItem(); + final CommandInfo command = matches.getSelectionModel().getSelectedItem(); if (command != null) run(command); } /** Runs a command, and says what came back. */ - private void run(final ExecutableInfo command) { + private void run(final CommandInfo command) { status.setText("Running " + command.label() + "..."); progress.setVisible(true); final Future future = runner.run(command, Map.of()); @@ -189,7 +189,7 @@ private void run(final ExecutableInfo command) { }, "shell-" + command.name()).start(); } - private static String describe(final ExecutableInfo command) { + private static String describe(final CommandInfo command) { return command.menuPath().map(path -> path.replace(">", " ▸ ")) // .orElseGet(() -> command.label() + " (not in any menu)"); } diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java index 77f112626..4cc8dd86c 100644 --- a/scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java +++ b/scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java @@ -59,7 +59,7 @@ *

* 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 ExecutableInfo.of(script, metadata)}, which is the same call any + * with {@code CommandInfo.of(script, metadata)}, which is the same call any * other source of runnable things makes. *

* diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java index 397242a98..535127dd0 100644 --- a/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java +++ b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java @@ -183,7 +183,7 @@ public List parameters() { *

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

*/ diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java index 71b55fd4b..74debb937 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java @@ -43,7 +43,7 @@ import javax.swing.KeyStroke; import org.scijava.command.Accelerator; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuCreator; import org.scijava.command.MenuTree; import org.scijava.command.Menus; @@ -64,20 +64,20 @@ public class SwingMenus implements MenuCreator { private static final Logger log = LoggerFactory.getLogger(SwingMenus.class); - private final Consumer onSelect; + 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) { + 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) + final Consumer onSelect) { return Menus.build(root, new JMenuBar(), new SwingMenus(onSelect)); } @@ -98,7 +98,7 @@ public JMenu subMenu(final MenuTree node, final JMenu parent) { @Override public void item(final MenuTree leaf, final JMenu parent) { - final ExecutableInfo command = leaf.entry().orElseThrow(); + final CommandInfo command = leaf.entry().orElseThrow(); final JMenuItem item = new JMenuItem(leaf.label()); command.accelerator().map(SwingMenus::keyStroke).ifPresent( item::setAccelerator); diff --git a/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java b/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java index 8d3ee1c69..e3de71e2a 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java +++ b/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java @@ -46,7 +46,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIf; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuTree; import org.scijava.script3.ScriptFinder; @@ -92,7 +92,7 @@ public void testDirectoryDerivedMenuPath() { /** What the menu holds is the thing to run, script or command alike. */ @Test public void testScriptRunsFromItsMenuEntry() { - final ExecutableInfo hello = MenuTree.of(entries()) // + final CommandInfo hello = MenuTree.of(entries()) // .find("Help>Say Hello From A Script").orElseThrow() // .entry().orElseThrow(); @@ -105,8 +105,8 @@ public void testScriptRunsFromItsMenuEntry() { // -- Helper methods -- - private static List entries() { - final List entries = new ArrayList<>(); + private static List entries() { + final List entries = new ArrayList<>(); try (final org.scijava.context.Context context = // org.scijava.context.Context.create()) { @@ -114,7 +114,7 @@ private static List entries() { .filter(c -> c.className().contains("SampleCommands")) // .forEach(entries::add); } - new ScriptFinder().find(scripts()).forEach(found -> entries.add(ExecutableInfo + new ScriptFinder().find(scripts()).forEach(found -> entries.add(CommandInfo .of(found.script(), found.metadata()))); return entries; } diff --git a/scijava-swing/src/test/java/org/scijava/swing/Shell.java b/scijava-swing/src/test/java/org/scijava/swing/Shell.java index f8742981e..9257d1e2b 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/Shell.java +++ b/scijava-swing/src/test/java/org/scijava/swing/Shell.java @@ -58,7 +58,7 @@ import javax.swing.event.DocumentListener; import org.scijava.command.Commands; -import org.scijava.command.ExecutableInfo; +import org.scijava.command.CommandInfo; import org.scijava.command.MenuTree; import org.scijava.context.Context; import org.scijava.execute.ExecutionResult; @@ -79,7 +79,7 @@ * 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 ExecutableInfo} - arranged by + * 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 @@ -91,16 +91,16 @@ public class Shell { private final Context context = Context.create(); - private final List commands = entries(); + 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 = + private final DefaultListModel matchModel = new DefaultListModel<>(); - private final JList matches = new JList<>(matchModel); + private final JList matches = new JList<>(matchModel); public Shell() { final SwingInputHarvester harvester = SwingInputHarvester.of(context); @@ -124,16 +124,16 @@ public static void main(final String... args) { * 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 ExecutableInfo}s, and where each came from - the annotation index, a + * {@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( + private List entries() { + final List entries = new ArrayList<>(Commands.discover( context)); scriptsDirectory().ifPresent(dir -> new ScriptFinder().find(dir).forEach( // - found -> entries.add(ExecutableInfo.of(found.script(), found.metadata())))); + found -> entries.add(CommandInfo.of(found.script(), found.metadata())))); return entries; } @@ -224,7 +224,7 @@ public void mouseClicked(final java.awt.event.MouseEvent e) { /** 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() // + final List found = commands.stream() // .filter(c -> describe(c).toLowerCase(Locale.ROOT).contains(text)) // .collect(Collectors.toList()); matchModel.clear(); @@ -234,12 +234,12 @@ private void filter() { } private void runSelected() { - final ExecutableInfo command = matches.getSelectedValue(); + final CommandInfo command = matches.getSelectedValue(); if (command != null) run(command); } /** Runs a command, and says what came back. */ - private void run(final ExecutableInfo command) { + private void run(final CommandInfo command) { status.setText("Running " + command.label() + "..."); progress.setIndeterminate(true); progress.setVisible(true); @@ -254,7 +254,7 @@ private void run(final ExecutableInfo command) { }, "shell-" + command.name()).start(); } - private static String describe(final ExecutableInfo command) { + private static String describe(final CommandInfo command) { return command.menuPath().map(path -> path.replace(">", " ▸ ")) // .orElseGet(() -> command.label() + " (not in any menu)"); } diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java index 2a8b4c7f7..cb222a754 100644 --- a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java +++ b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java @@ -36,7 +36,7 @@ import java.util.stream.Collectors; import org.junit.jupiter.api.Test; -import org.scijava.command.CommandInfo; +import org.scijava.command.ClassCommandInfo; import org.scijava.command.Commands; import org.scijava.command.MenuTree; import org.scijava.context.Context; @@ -94,14 +94,14 @@ public void testCommandWithNoMenuPath() { 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() // + 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() { + protected static List commands() { try (Context context = Context.create()) { return Commands.discover(context); } From 477a5446652b3a1e5971b3388cc4b2ef00b87946 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 22:48:13 -0500 Subject: [PATCH 46/59] The legacy bridge, and the split package it found `scijava-bridge` presents SciJava Common's modules as SciJava3 commands, so an application can show, harvest and run everything the old world already knows about -- every ImageJ2 command, every Fiji plugin ported to the module system -- beside its own commands and its scripts, in its own menus and dialogs, with neither side changed. It is the only component that depends on SciJava Common, and the dependency goes one way. Before any of it could work: **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; on the classpath it is worse, two different `Command` interfaces under one name with the first jar winning. Fiji could never have loaded both. The incarnation rule was applied to io3, convert3 and script3 and simply missed here, so this renames the component to scijava-command3 / org.scijava.command3, version 3.0.0-SNAPSHOT. Checking all 77 SciJava Common packages against all 34 SciJava3 ones: that was the only collision. What else the bridge turned up, each of which is a translation rather than a compromise: - SciJava Common's @Parameter means both "this is an input" and "inject this service"; 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 Service or Context parameter is the legacy context's business and never reaches a dialog. Without this, every DynamicCommand dialog would show four spurious rows. - DynamicCommand needs no SciJava3 equivalent to be supported. Its parameters live on the module's own description, so the bridge initializes at create() time, as InitPreprocessor does, and reads that description. What SciJava3 declines to offer as a way of writing commands, it presents without trouble. - Callbacks and validators resolve by name through behavior(String) -- the SPI that exists so scripts could have callbacks turns out to be exactly what legacy commands need. SciJava Common's two validator protocols (throw, or return a message) collapse into SciJava3's one, and its "Error executing method" wrapper is unwrapped so the user sees what the validator actually said. - Accelerators are translated rather than passed through: Accelerator.toString() renders Swing's "control L", which Swing parses back and JavaFX does not. Written ^L, all three bindings agree. - Menu paths are rebuilt from their entries, getMenuString() rendering "A > B > C" with spaces that would end up inside the labels. - LegacyContext keeps SciJava Common types out of the bridge's signatures: a JPMS module would otherwise need `requires org.scijava` merely to hold the context, and a test-scoped use cannot say that at all. The bridge's tests stop at the dialog *model*, which is where its claim ends; that a correct model becomes correct widgets is what the bindings' shared conformance suite already establishes three times over. NB: the monorepo already has a scijava-legacy, for ops and ImageJ2 interop, which `requires net.imagej`. Keeping the command bridge separate is deliberate: an application wanting Fiji's existing commands in its menus should not thereby acquire ImageJ2. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 54 ++- docs/migration.md | 7 +- pom.xml | 3 +- scijava-awt/pom.xml | 6 +- scijava-awt/src/main/java/module-info.java | 2 +- .../main/java/org/scijava/awt/AwtMenus.java | 10 +- .../java/org/scijava/awt/AwtMenusTest.java | 2 +- .../test/java/org/scijava/awt/AwtShell.java | 6 +- scijava-bridge/pom.xml | 234 +++++++++++ scijava-bridge/src/main/java/module-info.java | 51 +++ .../main/java/org/scijava/bridge/Legacy.java | 101 +++++ .../org/scijava/bridge/LegacyCommandInfo.java | 372 ++++++++++++++++++ .../org/scijava/bridge/LegacyContext.java | 113 ++++++ .../java/org/scijava/bridge/LegacyMember.java | 171 ++++++++ .../org/scijava/bridge/LegacyBridgeTest.java | 231 +++++++++++ .../org/scijava/bridge/LegacyCommands.java | 161 ++++++++ .../org/scijava/bridge/LegacyDialogTest.java | 142 +++++++ {scijava-command => scijava-command3}/pom.xml | 6 +- .../src/main/java/module-info.java | 6 +- .../org/scijava/command3}/Accelerator.java | 2 +- .../scijava/command3}/ClassCommandInfo.java | 2 +- .../java/org/scijava/command3}/Command.java | 2 +- .../org/scijava/command3}/CommandInfo.java | 2 +- .../java/org/scijava/command3}/Commands.java | 2 +- .../main/java/org/scijava/command3}/Menu.java | 2 +- .../org/scijava/command3}/MenuCreator.java | 2 +- .../java/org/scijava/command3}/MenuTree.java | 2 +- .../java/org/scijava/command3}/Menus.java | 2 +- .../org/scijava/command3}/CommandsTest.java | 10 +- .../org/scijava/command3}/CountBeans.java | 2 +- .../org/scijava/command3}/DeepCommand.java | 2 +- .../java/org/scijava/command3}/Headless.java | 2 +- .../org/scijava/command3}/HiddenCommand.java | 2 +- .../org/scijava/command3}/MenuTreeTest.java | 4 +- .../java/org/scijava/command3}/NeverRun.java | 2 +- .../java/org/scijava/command3}/SayHello.java | 2 +- scijava-javafx/pom.xml | 6 +- scijava-javafx/src/main/java/module-info.java | 2 +- .../main/java/org/scijava/javafx/FxMenus.java | 10 +- .../java/org/scijava/javafx/FxMenusTest.java | 2 +- .../test/java/org/scijava/javafx/FxShell.java | 6 +- .../org/scijava/script3/ScriptHeader.java | 2 +- scijava-swing/pom.xml | 11 +- scijava-swing/src/main/java/module-info.java | 2 +- .../java/org/scijava/swing/SwingMenus.java | 10 +- .../org/scijava/swing/ScriptMenuTest.java | 6 +- .../test/java/org/scijava/swing/Shell.java | 6 +- .../org/scijava/swing/SwingMenusTest.java | 2 +- scijava-ui3-test/pom.xml | 6 +- .../src/main/java/module-info.java | 2 +- .../org/scijava/ui3/test/MenuConformance.java | 6 +- .../org/scijava/ui3/test/SampleCommands.java | 4 +- 52 files changed, 1714 insertions(+), 91 deletions(-) create mode 100644 scijava-bridge/pom.xml create mode 100644 scijava-bridge/src/main/java/module-info.java create mode 100644 scijava-bridge/src/main/java/org/scijava/bridge/Legacy.java create mode 100644 scijava-bridge/src/main/java/org/scijava/bridge/LegacyCommandInfo.java create mode 100644 scijava-bridge/src/main/java/org/scijava/bridge/LegacyContext.java create mode 100644 scijava-bridge/src/main/java/org/scijava/bridge/LegacyMember.java create mode 100644 scijava-bridge/src/test/java/org/scijava/bridge/LegacyBridgeTest.java create mode 100644 scijava-bridge/src/test/java/org/scijava/bridge/LegacyCommands.java create mode 100644 scijava-bridge/src/test/java/org/scijava/bridge/LegacyDialogTest.java rename {scijava-command => scijava-command3}/pom.xml (97%) rename {scijava-command => scijava-command3}/src/main/java/module-info.java (94%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/Accelerator.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/ClassCommandInfo.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/Command.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/CommandInfo.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/Commands.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/Menu.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/MenuCreator.java (98%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/MenuTree.java (99%) rename {scijava-command/src/main/java/org/scijava/command => scijava-command3/src/main/java/org/scijava/command3}/Menus.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/CommandsTest.java (96%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/CountBeans.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/DeepCommand.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/Headless.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/HiddenCommand.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/MenuTreeTest.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/NeverRun.java (98%) rename {scijava-command/src/test/java/org/scijava/command => scijava-command3/src/test/java/org/scijava/command3}/SayHello.java (98%) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 89c151b3b..b3aa4189c 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -689,7 +689,7 @@ 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-command` (a struct plus presentation metadata, the menu tree and its walk), `scijava-ui3` (toolkit-agnostic contracts), `scijava-desktop` | layer 0 | +| **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 | @@ -755,8 +755,52 @@ So the **one-way legacy bridge matters more than its deferral 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, and it should be sequenced *with* layer 1 rather than -after it. +rather than a big bang. + +**`scijava-bridge` is built**, and it is the only component that depends on +SciJava Common. `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. ### On the words @@ -1026,7 +1070,7 @@ widgets and watching its callbacks fire. 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-command`, beside the tree they walk) hold + `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. @@ -1090,7 +1134,7 @@ widgets and watching its callbacks fire. 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-command`, because Swing wants a + - `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. diff --git a/docs/migration.md b/docs/migration.md index 78654ae86..0fe83d011 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,7 +86,7 @@ A **dropped** row is a successful outcome, not a gap. | `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-command` presentation metadata (layer 1). All of it survives — menu path, label, icon, accelerator, weight, selection group, visibility, enablement — but as attributes in the annotation index rather than an inheritance chain every plugin extends | planned | +| `BasicDetails`, `AbstractBasicDetails`, `UIDetails`, `AbstractUIDetails`, `MenuEntry`, `MenuPath` | `scijava-command3` presentation metadata (layer 1). All of it survives — menu path, label, icon, accelerator, weight, selection group, visibility, enablement — but as attributes in the annotation index rather than an inheritance chain every plugin extends | planned | | `Validated`, `ValidityProblem` | execution layer (Phase 3) | open | | `Optional` | `scijava-optional` (satellite repo, moves in later) | open | @@ -121,14 +121,15 @@ A **dropped** row is a successful outcome, not a gap. | `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.bridge`: `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.command`: `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 | +| `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.command`: `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 | +| `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`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | diff --git a/pom.xml b/pom.xml index 7d611fcdf..ea443bc2b 100644 --- a/pom.xml +++ b/pom.xml @@ -58,8 +58,9 @@ scijava-awt + scijava-bridge scijava-collections - scijava-command + scijava-command3 scijava-common3 scijava-context scijava-convert3 diff --git a/scijava-awt/pom.xml b/scijava-awt/pom.xml index c80720c84..37ed98951 100644 --- a/scijava-awt/pom.xml +++ b/scijava-awt/pom.xml @@ -86,7 +86,7 @@ SciJava developers. - 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT 1.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.0-SNAPSHOT @@ -108,8 +108,8 @@
org.scijava - scijava-command - ${scijava-command.version} + scijava-command3 + ${scijava-command3.version} org.scijava diff --git a/scijava-awt/src/main/java/module-info.java b/scijava-awt/src/main/java/module-info.java index e7fb462ae..7a55ce259 100644 --- a/scijava-awt/src/main/java/module-info.java +++ b/scijava-awt/src/main/java/module-info.java @@ -37,7 +37,7 @@ requires transitive java.desktop; requires transitive org.scijava.ui3; - requires transitive org.scijava.command; + requires transitive org.scijava.command3; requires transitive org.scijava.context; requires org.scijava.discovery; requires org.scijava.priority; diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java index c9949aec9..9ba49ad8a 100644 --- a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java +++ b/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java @@ -37,11 +37,11 @@ import java.util.Optional; import java.util.function.Consumer; -import org.scijava.command.Accelerator; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuCreator; -import org.scijava.command.MenuTree; -import org.scijava.command.Menus; +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; diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java b/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java index 8e7fec215..105e58bda 100644 --- a/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java +++ b/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java @@ -45,7 +45,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIf; -import org.scijava.command.MenuTree; +import org.scijava.command3.MenuTree; import org.scijava.ui3.test.MenuConformance; /** diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java index 98d5fb0a7..18c12ce8a 100644 --- a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java +++ b/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java @@ -42,9 +42,9 @@ import java.util.concurrent.Future; import java.util.stream.Collectors; -import org.scijava.command.Commands; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuTree; +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; diff --git a/scijava-bridge/pom.xml b/scijava-bridge/pom.xml new file mode 100644 index 000000000..8a5e2c4dc --- /dev/null +++ b/scijava-bridge/pom.xml @@ -0,0 +1,234 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 44.0.0 + + + + scijava-bridge + + 3.0.0-SNAPSHOT + + SciJava Bridge + Presenting SciJava Common's modules as SciJava3 commands. + 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.bridge + + 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/scijava-bridge/src/main/java/module-info.java b/scijava-bridge/src/main/java/module-info.java new file mode 100644 index 000000000..aad034d61 --- /dev/null +++ b/scijava-bridge/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.bridge { + + exports org.scijava.bridge; + + 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/scijava-bridge/src/main/java/org/scijava/bridge/Legacy.java b/scijava-bridge/src/main/java/org/scijava/bridge/Legacy.java new file mode 100644 index 000000000..cbb8fb427 --- /dev/null +++ b/scijava-bridge/src/main/java/org/scijava/bridge/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.bridge; + +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/scijava-bridge/src/main/java/org/scijava/bridge/LegacyCommandInfo.java b/scijava-bridge/src/main/java/org/scijava/bridge/LegacyCommandInfo.java new file mode 100644 index 000000000..90e537eb2 --- /dev/null +++ b/scijava-bridge/src/main/java/org/scijava/bridge/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.bridge; + +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/scijava-bridge/src/main/java/org/scijava/bridge/LegacyContext.java b/scijava-bridge/src/main/java/org/scijava/bridge/LegacyContext.java new file mode 100644 index 000000000..6a5ec537d --- /dev/null +++ b/scijava-bridge/src/main/java/org/scijava/bridge/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.bridge; + +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/scijava-bridge/src/main/java/org/scijava/bridge/LegacyMember.java b/scijava-bridge/src/main/java/org/scijava/bridge/LegacyMember.java new file mode 100644 index 000000000..ebe32285a --- /dev/null +++ b/scijava-bridge/src/main/java/org/scijava/bridge/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.bridge; + +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/scijava-bridge/src/test/java/org/scijava/bridge/LegacyBridgeTest.java b/scijava-bridge/src/test/java/org/scijava/bridge/LegacyBridgeTest.java new file mode 100644 index 000000000..296b0f84c --- /dev/null +++ b/scijava-bridge/src/test/java/org/scijava/bridge/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.bridge; + +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/scijava-bridge/src/test/java/org/scijava/bridge/LegacyCommands.java b/scijava-bridge/src/test/java/org/scijava/bridge/LegacyCommands.java new file mode 100644 index 000000000..8cd3c8afd --- /dev/null +++ b/scijava-bridge/src/test/java/org/scijava/bridge/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.bridge; + +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/scijava-bridge/src/test/java/org/scijava/bridge/LegacyDialogTest.java b/scijava-bridge/src/test/java/org/scijava/bridge/LegacyDialogTest.java new file mode 100644 index 000000000..b3932f342 --- /dev/null +++ b/scijava-bridge/src/test/java/org/scijava/bridge/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.bridge; + +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-command/pom.xml b/scijava-command3/pom.xml similarity index 97% rename from scijava-command/pom.xml rename to scijava-command3/pom.xml index 268907ef2..0b2c96a9f 100644 --- a/scijava-command/pom.xml +++ b/scijava-command3/pom.xml @@ -9,8 +9,8 @@ - scijava-command - 1.0.0-SNAPSHOT + scijava-command3 + 3.0.0-SNAPSHOT SciJava Command Discoverable commands, with the metadata a menu is built from. @@ -80,7 +80,7 @@ - org.scijava.command + org.scijava.command3 bsd_2 SciJava developers. diff --git a/scijava-command/src/main/java/module-info.java b/scijava-command3/src/main/java/module-info.java similarity index 94% rename from scijava-command/src/main/java/module-info.java rename to scijava-command3/src/main/java/module-info.java index 0b644b892..6ed5496c4 100644 --- a/scijava-command/src/main/java/module-info.java +++ b/scijava-command3/src/main/java/module-info.java @@ -27,15 +27,15 @@ * #L% */ -module org.scijava.command { +module org.scijava.command3 { - exports org.scijava.command; + 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.command to org.scijava.context; + opens org.scijava.command3 to org.scijava.context; requires transitive org.scijava.context; requires transitive org.scijava.execute; diff --git a/scijava-command/src/main/java/org/scijava/command/Accelerator.java b/scijava-command3/src/main/java/org/scijava/command3/Accelerator.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/Accelerator.java rename to scijava-command3/src/main/java/org/scijava/command3/Accelerator.java index 653abb16e..961b5d612 100644 --- a/scijava-command/src/main/java/org/scijava/command/Accelerator.java +++ b/scijava-command3/src/main/java/org/scijava/command3/Accelerator.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import java.util.Optional; diff --git a/scijava-command/src/main/java/org/scijava/command/ClassCommandInfo.java b/scijava-command3/src/main/java/org/scijava/command3/ClassCommandInfo.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/ClassCommandInfo.java rename to scijava-command3/src/main/java/org/scijava/command3/ClassCommandInfo.java index 1e97bbbd3..c0235b4f2 100644 --- a/scijava-command/src/main/java/org/scijava/command/ClassCommandInfo.java +++ b/scijava-command3/src/main/java/org/scijava/command3/ClassCommandInfo.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import java.util.Map; import java.util.Optional; diff --git a/scijava-command/src/main/java/org/scijava/command/Command.java b/scijava-command3/src/main/java/org/scijava/command3/Command.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/Command.java rename to scijava-command3/src/main/java/org/scijava/command3/Command.java index 93a8545f1..d65a3d201 100644 --- a/scijava-command/src/main/java/org/scijava/command/Command.java +++ b/scijava-command3/src/main/java/org/scijava/command3/Command.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; /** * Something a user can run: the unit a menu item, a script and a recorded diff --git a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java b/scijava-command3/src/main/java/org/scijava/command3/CommandInfo.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/CommandInfo.java rename to scijava-command3/src/main/java/org/scijava/command3/CommandInfo.java index 6eac3aee1..caa240e72 100644 --- a/scijava-command/src/main/java/org/scijava/command/CommandInfo.java +++ b/scijava-command3/src/main/java/org/scijava/command3/CommandInfo.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import java.util.Map; import java.util.Optional; diff --git a/scijava-command/src/main/java/org/scijava/command/Commands.java b/scijava-command3/src/main/java/org/scijava/command3/Commands.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/Commands.java rename to scijava-command3/src/main/java/org/scijava/command3/Commands.java index 71fd76b94..80d3dca0f 100644 --- a/scijava-command/src/main/java/org/scijava/command/Commands.java +++ b/scijava-command3/src/main/java/org/scijava/command3/Commands.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import java.util.ArrayList; import java.util.Comparator; diff --git a/scijava-command/src/main/java/org/scijava/command/Menu.java b/scijava-command3/src/main/java/org/scijava/command3/Menu.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/Menu.java rename to scijava-command3/src/main/java/org/scijava/command3/Menu.java index 0ac76722f..eaa6353d3 100644 --- a/scijava-command/src/main/java/org/scijava/command/Menu.java +++ b/scijava-command3/src/main/java/org/scijava/command3/Menu.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/scijava-command/src/main/java/org/scijava/command/MenuCreator.java b/scijava-command3/src/main/java/org/scijava/command3/MenuCreator.java similarity index 98% rename from scijava-command/src/main/java/org/scijava/command/MenuCreator.java rename to scijava-command3/src/main/java/org/scijava/command3/MenuCreator.java index 133032c48..54d8a7349 100644 --- a/scijava-command/src/main/java/org/scijava/command/MenuCreator.java +++ b/scijava-command3/src/main/java/org/scijava/command3/MenuCreator.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; /** * Builds a toolkit's menus from a {@link MenuTree}. diff --git a/scijava-command/src/main/java/org/scijava/command/MenuTree.java b/scijava-command3/src/main/java/org/scijava/command3/MenuTree.java similarity index 99% rename from scijava-command/src/main/java/org/scijava/command/MenuTree.java rename to scijava-command3/src/main/java/org/scijava/command3/MenuTree.java index cece7b47d..66b3920b2 100644 --- a/scijava-command/src/main/java/org/scijava/command/MenuTree.java +++ b/scijava-command3/src/main/java/org/scijava/command3/MenuTree.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import java.util.ArrayList; import java.util.Collection; diff --git a/scijava-command/src/main/java/org/scijava/command/Menus.java b/scijava-command3/src/main/java/org/scijava/command3/Menus.java similarity index 98% rename from scijava-command/src/main/java/org/scijava/command/Menus.java rename to scijava-command3/src/main/java/org/scijava/command3/Menus.java index 0f063b2ad..5984eb11f 100644 --- a/scijava-command/src/main/java/org/scijava/command/Menus.java +++ b/scijava-command3/src/main/java/org/scijava/command3/Menus.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; /** * Walks a {@link MenuTree} into a toolkit's menus. diff --git a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java b/scijava-command3/src/test/java/org/scijava/command3/CommandsTest.java similarity index 96% rename from scijava-command/src/test/java/org/scijava/command/CommandsTest.java rename to scijava-command3/src/test/java/org/scijava/command3/CommandsTest.java index cf2414fa0..2e11a6264 100644 --- a/scijava-command/src/test/java/org/scijava/command/CommandsTest.java +++ b/scijava-command3/src/test/java/org/scijava/command3/CommandsTest.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -85,7 +85,7 @@ public void testMenuMetadataWithoutLoadingCommands() { // methods by declaration. final ClassCommandInfo never = find(context, NeverRun.class); assertEquals("Help>Never Run", never.menuPath().orElse(null)); - assertFalse(constructed("org.scijava.command.NeverRun"), + assertFalse(constructed("org.scijava.command3.NeverRun"), "building a menu must not construct commands"); } } @@ -132,7 +132,7 @@ public void testRunADiscoveredCommand() throws Exception { assertTrue(result.isCompleted()); assertEquals(Map.of("greeting", "hello ada"), result.outputs()); // Only now is the class loaded and constructed. - assertTrue(constructed("org.scijava.command.SayHello")); + assertTrue(constructed("org.scijava.command3.SayHello")); } } @@ -169,9 +169,9 @@ public void testOneOpensIsEnough() { final Module context = Context.class.getModule(); final Module execute = org.scijava.execute.Executables.class.getModule(); - assertTrue(module.isOpen("org.scijava.command", context), + assertTrue(module.isOpen("org.scijava.command3", context), "commands must be open to the container"); - assertFalse(module.isOpen("org.scijava.command", execute), + assertFalse(module.isOpen("org.scijava.command3", execute), "commands should not need opening to the execution layer as well"); } diff --git a/scijava-command/src/test/java/org/scijava/command/CountBeans.java b/scijava-command3/src/test/java/org/scijava/command3/CountBeans.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/CountBeans.java rename to scijava-command3/src/test/java/org/scijava/command3/CountBeans.java index 978e027e3..4cdb7bb42 100644 --- a/scijava-command/src/test/java/org/scijava/command/CountBeans.java +++ b/scijava-command3/src/test/java/org/scijava/command3/CountBeans.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import org.scijava.context.Plugin; import org.scijava.execute.Parameter; diff --git a/scijava-command/src/test/java/org/scijava/command/DeepCommand.java b/scijava-command3/src/test/java/org/scijava/command3/DeepCommand.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/DeepCommand.java rename to scijava-command3/src/test/java/org/scijava/command3/DeepCommand.java index 2efc35fcd..86d62a495 100644 --- a/scijava-command/src/test/java/org/scijava/command/DeepCommand.java +++ b/scijava-command3/src/test/java/org/scijava/command3/DeepCommand.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import org.scijava.context.Plugin; import org.scijava.execute.Parameter; diff --git a/scijava-command/src/test/java/org/scijava/command/Headless.java b/scijava-command3/src/test/java/org/scijava/command3/Headless.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/Headless.java rename to scijava-command3/src/test/java/org/scijava/command3/Headless.java index d81c853a9..7648d3714 100644 --- a/scijava-command/src/test/java/org/scijava/command/Headless.java +++ b/scijava-command3/src/test/java/org/scijava/command3/Headless.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import org.scijava.context.Plugin; import org.scijava.execute.Parameter; diff --git a/scijava-command/src/test/java/org/scijava/command/HiddenCommand.java b/scijava-command3/src/test/java/org/scijava/command3/HiddenCommand.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/HiddenCommand.java rename to scijava-command3/src/test/java/org/scijava/command3/HiddenCommand.java index 3fb631200..ecbb5db08 100644 --- a/scijava-command/src/test/java/org/scijava/command/HiddenCommand.java +++ b/scijava-command3/src/test/java/org/scijava/command3/HiddenCommand.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import org.scijava.context.Plugin; diff --git a/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java b/scijava-command3/src/test/java/org/scijava/command3/MenuTreeTest.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java rename to scijava-command3/src/test/java/org/scijava/command3/MenuTreeTest.java index 8f0e4022b..a15f29262 100644 --- a/scijava-command/src/test/java/org/scijava/command/MenuTreeTest.java +++ b/scijava-command3/src/test/java/org/scijava/command3/MenuTreeTest.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -119,7 +119,7 @@ public void testTreeIsBuiltFromMetadataAlone() { 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.command.NeverRun"), + assertFalse(constructed("org.scijava.command3.NeverRun"), "building a menu tree must not construct commands"); } } diff --git a/scijava-command/src/test/java/org/scijava/command/NeverRun.java b/scijava-command3/src/test/java/org/scijava/command3/NeverRun.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/NeverRun.java rename to scijava-command3/src/test/java/org/scijava/command3/NeverRun.java index e7117a066..3c6856ccd 100644 --- a/scijava-command/src/test/java/org/scijava/command/NeverRun.java +++ b/scijava-command3/src/test/java/org/scijava/command3/NeverRun.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import org.scijava.context.Plugin; import org.scijava.execute.Parameter; diff --git a/scijava-command/src/test/java/org/scijava/command/SayHello.java b/scijava-command3/src/test/java/org/scijava/command3/SayHello.java similarity index 98% rename from scijava-command/src/test/java/org/scijava/command/SayHello.java rename to scijava-command3/src/test/java/org/scijava/command3/SayHello.java index e005a956c..2954189a8 100644 --- a/scijava-command/src/test/java/org/scijava/command/SayHello.java +++ b/scijava-command3/src/test/java/org/scijava/command3/SayHello.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.command; +package org.scijava.command3; import org.scijava.context.Plugin; import org.scijava.execute.Parameter; diff --git a/scijava-javafx/pom.xml b/scijava-javafx/pom.xml index 809d2e1ee..7c674eb64 100644 --- a/scijava-javafx/pom.xml +++ b/scijava-javafx/pom.xml @@ -89,7 +89,7 @@ 17.0.21 - 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT 1.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.0-SNAPSHOT @@ -111,8 +111,8 @@
org.scijava - scijava-command - ${scijava-command.version} + scijava-command3 + ${scijava-command3.version} org.scijava diff --git a/scijava-javafx/src/main/java/module-info.java b/scijava-javafx/src/main/java/module-info.java index 232fc58c2..29a8180ed 100644 --- a/scijava-javafx/src/main/java/module-info.java +++ b/scijava-javafx/src/main/java/module-info.java @@ -39,7 +39,7 @@ requires transitive javafx.controls; requires transitive javafx.graphics; requires transitive org.scijava.ui3; - requires transitive org.scijava.command; + requires transitive org.scijava.command3; requires transitive org.scijava.context; requires org.scijava.discovery; requires org.scijava.priority; diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java index c461b2f6d..bd81f7fc5 100644 --- a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java @@ -43,11 +43,11 @@ import javafx.scene.input.KeyCombination; import javafx.scene.input.KeyCombination.Modifier; -import org.scijava.command.Accelerator; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuCreator; -import org.scijava.command.MenuTree; -import org.scijava.command.Menus; +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; diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java index cc7edf4f7..6587b4db0 100644 --- a/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java @@ -37,7 +37,7 @@ import javafx.scene.control.MenuItem; import org.junit.jupiter.api.condition.DisabledIf; -import org.scijava.command.MenuTree; +import org.scijava.command3.MenuTree; import org.scijava.ui3.test.MenuConformance; /** diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java index 38c80c198..d8cbd5f7d 100644 --- a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java +++ b/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java @@ -50,9 +50,9 @@ import javafx.scene.layout.VBox; import javafx.stage.Stage; -import org.scijava.command.Commands; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuTree; +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; diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java index 535127dd0..d9798af4a 100644 --- a/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java +++ b/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java @@ -183,7 +183,7 @@ public List parameters() { *

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

*/ diff --git a/scijava-swing/pom.xml b/scijava-swing/pom.xml index 8bfc72667..c06d4b2cc 100644 --- a/scijava-swing/pom.xml +++ b/scijava-swing/pom.xml @@ -86,7 +86,7 @@ SciJava developers. - 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT 1.0.0-SNAPSHOT 1.0.1-SNAPSHOT 1.0.0-SNAPSHOT @@ -108,8 +108,8 @@
org.scijava - scijava-command - ${scijava-command.version} + scijava-command3 + ${scijava-command3.version} org.scijava @@ -161,8 +161,9 @@ org.scijava diff --git a/scijava-swing/src/main/java/module-info.java b/scijava-swing/src/main/java/module-info.java index 82a6d5dec..0225a176f 100644 --- a/scijava-swing/src/main/java/module-info.java +++ b/scijava-swing/src/main/java/module-info.java @@ -37,7 +37,7 @@ requires transitive java.desktop; requires transitive org.scijava.ui3; - requires transitive org.scijava.command; + requires transitive org.scijava.command3; requires transitive org.scijava.context; requires org.scijava.discovery; requires org.scijava.priority; diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java index 74debb937..1b315bf7f 100644 --- a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java +++ b/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java @@ -42,11 +42,11 @@ import javax.swing.JMenuItem; import javax.swing.KeyStroke; -import org.scijava.command.Accelerator; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuCreator; -import org.scijava.command.MenuTree; -import org.scijava.command.Menus; +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; diff --git a/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java b/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java index e3de71e2a..2ab96ba62 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java +++ b/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java @@ -46,8 +46,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIf; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuTree; +import org.scijava.command3.CommandInfo; +import org.scijava.command3.MenuTree; import org.scijava.script3.ScriptFinder; /** @@ -110,7 +110,7 @@ private static List entries() { try (final org.scijava.context.Context context = // org.scijava.context.Context.create()) { - org.scijava.command.Commands.discover(context).stream() // + org.scijava.command3.Commands.discover(context).stream() // .filter(c -> c.className().contains("SampleCommands")) // .forEach(entries::add); } diff --git a/scijava-swing/src/test/java/org/scijava/swing/Shell.java b/scijava-swing/src/test/java/org/scijava/swing/Shell.java index 9257d1e2b..38f37b0d6 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/Shell.java +++ b/scijava-swing/src/test/java/org/scijava/swing/Shell.java @@ -57,9 +57,9 @@ import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import org.scijava.command.Commands; -import org.scijava.command.CommandInfo; -import org.scijava.command.MenuTree; +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; diff --git a/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java b/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java index bdc8e433c..391fe107c 100644 --- a/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java +++ b/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java @@ -47,7 +47,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledIf; -import org.scijava.command.MenuTree; +import org.scijava.command3.MenuTree; import org.scijava.ui3.test.MenuConformance; /** diff --git a/scijava-ui3-test/pom.xml b/scijava-ui3-test/pom.xml index b284aad1d..cd9b39bef 100644 --- a/scijava-ui3-test/pom.xml +++ b/scijava-ui3-test/pom.xml @@ -89,7 +89,7 @@ true - 1.0.0-SNAPSHOT + 3.0.0-SNAPSHOT 1.0.0-SNAPSHOT 1.0.0-SNAPSHOT 3.0.0-SNAPSHOT @@ -103,8 +103,8 @@ org.scijava - scijava-command - ${scijava-command.version} + scijava-command3 + ${scijava-command3.version} org.scijava diff --git a/scijava-ui3-test/src/main/java/module-info.java b/scijava-ui3-test/src/main/java/module-info.java index 8330a3070..ac8868bce 100644 --- a/scijava-ui3-test/src/main/java/module-info.java +++ b/scijava-ui3-test/src/main/java/module-info.java @@ -31,7 +31,7 @@ exports org.scijava.ui3.test; - requires transitive org.scijava.command; + requires transitive org.scijava.command3; requires transitive org.scijava.context; requires transitive org.scijava.ui3; requires transitive org.junit.jupiter.api; diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java index cb222a754..939e216bb 100644 --- a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java +++ b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java @@ -36,9 +36,9 @@ import java.util.stream.Collectors; import org.junit.jupiter.api.Test; -import org.scijava.command.ClassCommandInfo; -import org.scijava.command.Commands; -import org.scijava.command.MenuTree; +import org.scijava.command3.ClassCommandInfo; +import org.scijava.command3.Commands; +import org.scijava.command3.MenuTree; import org.scijava.context.Context; /** diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java index 3da3f7fca..6f5105e5d 100644 --- a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java +++ b/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java @@ -33,8 +33,8 @@ import java.util.List; import java.util.Map; -import org.scijava.command.Command; -import org.scijava.command.Menu; +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; From 1f0084d8b70b06cc29879550d9aa0a7fada7ae1a Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Thu, 17 Sep 2026 23:28:02 -0500 Subject: [PATCH 47/59] One name per foreign library: scijava-compat, -ops-compat, -imagej-compat scijava-bridge and the existing scijava-legacy were two names for what reads as the same job, which was going to confuse people. Now the compatibility components are named for the foreign thing each adapts to: scijava-compat SciJava Common's commands, usable from SciJava3 scijava-ops-compat SciJava3's ops, usable from a SciJava Common app scijava-imagej-compat ImageJ2's types, readable by scijava-types The first two are the same boundary in opposite directions, kept apart because their dependencies are disjoint: an application wanting Fiji's existing commands in its menus should not thereby acquire ops. Scrutinizing the dependencies, as suggested, is what shook the third one out. scijava-legacy held two SciJava Common/ops classes and one ImageJ2 type extractor, and its module-info said `requires net.imagej` for all of it -- so anyone wanting ops inside a SciJava Common application acquired ImageJ2 on account of a class that has nothing to do with SciJava Common. Being entirely about net.imagej.Dataset, it is a candidate for moving to an ImageJ repository; its own module-info says so. The split cost five build iterations, every one 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. So module boundaries here cannot be drawn by reading imports, and single-module -pl builds cannot check them: they resolve released artifacts, some a year old, rather than this reactor's. Each restored dependency now carries an NB saying why it is there. The old POM already warned about exactly this, in a comment I pruned past before reading. Also: scijava-imagej-compat inherited an annotation-processor configuration for SciJava Common's processor, in a component whose whole point is that it has nothing to do with SciJava Common. Removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013gJpzGd3ksbME4CN6Tu2ni --- docs/migration-plan.md | 72 +++-- docs/migration.md | 2 +- pom.xml | 4 +- {scijava-bridge => scijava-compat}/pom.xml | 10 +- .../src/main/java/module-info.java | 4 +- .../main/java/org/scijava/compat}/Legacy.java | 2 +- .../scijava/compat}/LegacyCommandInfo.java | 2 +- .../org/scijava/compat}/LegacyContext.java | 2 +- .../org/scijava/compat}/LegacyMember.java | 2 +- .../org/scijava/compat}/LegacyBridgeTest.java | 2 +- .../org/scijava/compat}/LegacyCommands.java | 2 +- .../org/scijava/compat}/LegacyDialogTest.java | 2 +- scijava-imagej-compat/pom.xml | 294 ++++++++++++++++++ .../src/main/java/module-info.java | 49 +++ .../imagej/compat}/DatasetTypeExtractor.java | 2 +- .../compat}/DatasetTypeExtractorTest.java | 2 +- .../LICENSE.txt | 0 .../README.md | 0 .../pom.xml | 50 ++- .../src/main/java/module-info.java | 9 +- .../compat}/DefaultOpEnvironmentService.java | 2 +- .../OpEnvironmentPreprocessorPlugin.java | 3 +- .../ops/compat}/OpEnvironmentService.java | 2 +- .../org.scijava.types.extract.TypeExtractor | 0 .../OpEnvironmentPreprocessorTest.java | 3 +- .../ops/compat}/OpEnvironmentServiceTest.java | 2 +- scijava-swing/pom.xml | 2 +- 27 files changed, 446 insertions(+), 80 deletions(-) rename {scijava-bridge => scijava-compat}/pom.xml (96%) rename {scijava-bridge => scijava-compat}/src/main/java/module-info.java (97%) rename {scijava-bridge/src/main/java/org/scijava/bridge => scijava-compat/src/main/java/org/scijava/compat}/Legacy.java (99%) rename {scijava-bridge/src/main/java/org/scijava/bridge => scijava-compat/src/main/java/org/scijava/compat}/LegacyCommandInfo.java (99%) rename {scijava-bridge/src/main/java/org/scijava/bridge => scijava-compat/src/main/java/org/scijava/compat}/LegacyContext.java (99%) rename {scijava-bridge/src/main/java/org/scijava/bridge => scijava-compat/src/main/java/org/scijava/compat}/LegacyMember.java (99%) rename {scijava-bridge/src/test/java/org/scijava/bridge => scijava-compat/src/test/java/org/scijava/compat}/LegacyBridgeTest.java (99%) rename {scijava-bridge/src/test/java/org/scijava/bridge => scijava-compat/src/test/java/org/scijava/compat}/LegacyCommands.java (99%) rename {scijava-bridge/src/test/java/org/scijava/bridge => scijava-compat/src/test/java/org/scijava/compat}/LegacyDialogTest.java (99%) create mode 100644 scijava-imagej-compat/pom.xml create mode 100644 scijava-imagej-compat/src/main/java/module-info.java rename {scijava-legacy/src/main/java/org/scijava/legacy/types => scijava-imagej-compat/src/main/java/org/scijava/imagej/compat}/DatasetTypeExtractor.java (98%) rename {scijava-legacy/src/test/java/org/scijava/legacy/types => scijava-imagej-compat/src/test/java/org/scijava/imagej/compat}/DatasetTypeExtractorTest.java (99%) rename {scijava-legacy => scijava-ops-compat}/LICENSE.txt (100%) rename {scijava-legacy => scijava-ops-compat}/README.md (100%) rename {scijava-legacy => scijava-ops-compat}/pom.xml (91%) rename {scijava-legacy => scijava-ops-compat}/src/main/java/module-info.java (87%) rename {scijava-legacy/src/main/java/org/scijava/legacy/service => scijava-ops-compat/src/main/java/org/scijava/ops/compat}/DefaultOpEnvironmentService.java (99%) rename {scijava-legacy/src/main/java/org/scijava/legacy/module/process => scijava-ops-compat/src/main/java/org/scijava/ops/compat}/OpEnvironmentPreprocessorPlugin.java (96%) rename {scijava-legacy/src/main/java/org/scijava/legacy/service => scijava-ops-compat/src/main/java/org/scijava/ops/compat}/OpEnvironmentService.java (98%) rename {scijava-legacy => scijava-ops-compat}/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor (100%) rename {scijava-legacy/src/test/java/org/scijava/legacy/module/process => scijava-ops-compat/src/test/java/org/scijava/ops/compat}/OpEnvironmentPreprocessorTest.java (96%) rename {scijava-legacy/src/test/java/org/scijava/legacy/service => scijava-ops-compat/src/test/java/org/scijava/ops/compat}/OpEnvironmentServiceTest.java (99%) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index b3aa4189c..28f84d402 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -81,8 +81,10 @@ The deliverable is not a reimplementation of SJC. It is: 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. **`scijava-legacy` bridges the other way** (SJ3 → SJC), and is the only - place in this repository permitted to depend on `scijava-common`. +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 @@ -114,7 +116,8 @@ they need the corresponding SJC classes deprecated with pointers. Remaining SJC usage inside this repository: -- `scijava-legacy` — intentional; it is the bridge. +- `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. @@ -569,8 +572,8 @@ No application context required by anything in this phase. 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 one-way legacy bridge, and the core services - themselves. + - 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 @@ -598,8 +601,9 @@ No application context required by anything in this phase. - **`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:** a service hosting an SJC `Context` inside the SJ3 - context, plus preprocessors that inject legacy objects into existing +- **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. @@ -751,17 +755,17 @@ 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 deferral 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 +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-bridge` is built**, and it is the only component that depends on -SciJava Common. `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. +**`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: @@ -802,6 +806,35 @@ What it found, which is why bridges are worth building early: 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: @@ -1234,7 +1267,8 @@ widgets and watching its callbacks fire. 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-legacy` only if a concrete need exists. +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. @@ -1316,10 +1350,6 @@ design settled now, so that Phase 4 inherits decisions rather than a debate: ## Open decisions - **Names:** the execution-layer module, and `scijava-convert3`. -- **Splitting `scijava-legacy`** into an SJC-only bridge and a separate - ImageJ-specific module, so that a bridge needing only SJC does not drag in - `imagej-common`. Agreed in principle; not blocking, so it is deferred until - the bridge content grows. - **`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 diff --git a/docs/migration.md b/docs/migration.md index 0fe83d011..afac27f9a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -121,7 +121,7 @@ A **dropped** row is a successful outcome, not a gap. | `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.bridge`: `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 | +| 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 | diff --git a/pom.xml b/pom.xml index ea443bc2b..f877ac8c0 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,7 @@ scijava-awt scijava-bridge + scijava-compat scijava-collections scijava-command3 scijava-common3 @@ -75,7 +76,8 @@ scijava-index scijava-io3 scijava-javafx - scijava-legacy + scijava-imagej-compat + scijava-ops-compat scijava-meta scijava-ops-api scijava-ops-engine diff --git a/scijava-bridge/pom.xml b/scijava-compat/pom.xml similarity index 96% rename from scijava-bridge/pom.xml rename to scijava-compat/pom.xml index 8a5e2c4dc..a20089609 100644 --- a/scijava-bridge/pom.xml +++ b/scijava-compat/pom.xml @@ -9,16 +9,16 @@ - scijava-bridge + scijava-compat - 3.0.0-SNAPSHOT + 1.0.0-SNAPSHOT - SciJava Bridge - Presenting SciJava Common's modules as SciJava3 commands. + SciJava Compat + Backwards compatibility with SciJava Common. https://github.com/scijava/scijava 2026 @@ -85,7 +85,7 @@ - org.scijava.bridge + org.scijava.compat bsd_2 SciJava developers. diff --git a/scijava-bridge/src/main/java/module-info.java b/scijava-compat/src/main/java/module-info.java similarity index 97% rename from scijava-bridge/src/main/java/module-info.java rename to scijava-compat/src/main/java/module-info.java index aad034d61..d4518acb8 100644 --- a/scijava-bridge/src/main/java/module-info.java +++ b/scijava-compat/src/main/java/module-info.java @@ -38,9 +38,9 @@ * package is a JVM that refuses to start. *

*/ -open module org.scijava.bridge { +open module org.scijava.compat { - exports org.scijava.bridge; + exports org.scijava.compat; requires transitive org.scijava; requires transitive org.scijava.command3; diff --git a/scijava-bridge/src/main/java/org/scijava/bridge/Legacy.java b/scijava-compat/src/main/java/org/scijava/compat/Legacy.java similarity index 99% rename from scijava-bridge/src/main/java/org/scijava/bridge/Legacy.java rename to scijava-compat/src/main/java/org/scijava/compat/Legacy.java index cbb8fb427..df6c84282 100644 --- a/scijava-bridge/src/main/java/org/scijava/bridge/Legacy.java +++ b/scijava-compat/src/main/java/org/scijava/compat/Legacy.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import java.util.Comparator; import java.util.List; diff --git a/scijava-bridge/src/main/java/org/scijava/bridge/LegacyCommandInfo.java b/scijava-compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java similarity index 99% rename from scijava-bridge/src/main/java/org/scijava/bridge/LegacyCommandInfo.java rename to scijava-compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java index 90e537eb2..e393390c1 100644 --- a/scijava-bridge/src/main/java/org/scijava/bridge/LegacyCommandInfo.java +++ b/scijava-compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import java.util.ArrayList; import java.util.List; diff --git a/scijava-bridge/src/main/java/org/scijava/bridge/LegacyContext.java b/scijava-compat/src/main/java/org/scijava/compat/LegacyContext.java similarity index 99% rename from scijava-bridge/src/main/java/org/scijava/bridge/LegacyContext.java rename to scijava-compat/src/main/java/org/scijava/compat/LegacyContext.java index 6a5ec537d..2b50284d8 100644 --- a/scijava-bridge/src/main/java/org/scijava/bridge/LegacyContext.java +++ b/scijava-compat/src/main/java/org/scijava/compat/LegacyContext.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import java.util.List; diff --git a/scijava-bridge/src/main/java/org/scijava/bridge/LegacyMember.java b/scijava-compat/src/main/java/org/scijava/compat/LegacyMember.java similarity index 99% rename from scijava-bridge/src/main/java/org/scijava/bridge/LegacyMember.java rename to scijava-compat/src/main/java/org/scijava/compat/LegacyMember.java index ebe32285a..f77aed2f8 100644 --- a/scijava-bridge/src/main/java/org/scijava/bridge/LegacyMember.java +++ b/scijava-compat/src/main/java/org/scijava/compat/LegacyMember.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import java.lang.reflect.Type; import java.util.LinkedHashMap; diff --git a/scijava-bridge/src/test/java/org/scijava/bridge/LegacyBridgeTest.java b/scijava-compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java similarity index 99% rename from scijava-bridge/src/test/java/org/scijava/bridge/LegacyBridgeTest.java rename to scijava-compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java index 296b0f84c..7cc11e35e 100644 --- a/scijava-bridge/src/test/java/org/scijava/bridge/LegacyBridgeTest.java +++ b/scijava-compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/scijava-bridge/src/test/java/org/scijava/bridge/LegacyCommands.java b/scijava-compat/src/test/java/org/scijava/compat/LegacyCommands.java similarity index 99% rename from scijava-bridge/src/test/java/org/scijava/bridge/LegacyCommands.java rename to scijava-compat/src/test/java/org/scijava/compat/LegacyCommands.java index 8cd3c8afd..a9efca83c 100644 --- a/scijava-bridge/src/test/java/org/scijava/bridge/LegacyCommands.java +++ b/scijava-compat/src/test/java/org/scijava/compat/LegacyCommands.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import java.io.File; diff --git a/scijava-bridge/src/test/java/org/scijava/bridge/LegacyDialogTest.java b/scijava-compat/src/test/java/org/scijava/compat/LegacyDialogTest.java similarity index 99% rename from scijava-bridge/src/test/java/org/scijava/bridge/LegacyDialogTest.java rename to scijava-compat/src/test/java/org/scijava/compat/LegacyDialogTest.java index b3932f342..2806800ae 100644 --- a/scijava-bridge/src/test/java/org/scijava/bridge/LegacyDialogTest.java +++ b/scijava-compat/src/test/java/org/scijava/compat/LegacyDialogTest.java @@ -27,7 +27,7 @@ * #L% */ -package org.scijava.bridge; +package org.scijava.compat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/scijava-imagej-compat/pom.xml b/scijava-imagej-compat/pom.xml new file mode 100644 index 000000000..f4782e79a --- /dev/null +++ b/scijava-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/scijava-imagej-compat/src/main/java/module-info.java b/scijava-imagej-compat/src/main/java/module-info.java new file mode 100644 index 000000000..c4d408bb3 --- /dev/null +++ b/scijava-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/scijava-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 scijava-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/scijava-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/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java b/scijava-imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java similarity index 99% rename from scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java rename to scijava-imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java index ab63755ec..447c9e280 100644 --- a/scijava-legacy/src/test/java/org/scijava/legacy/types/DatasetTypeExtractorTest.java +++ b/scijava-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; diff --git a/scijava-legacy/LICENSE.txt b/scijava-ops-compat/LICENSE.txt similarity index 100% rename from scijava-legacy/LICENSE.txt rename to scijava-ops-compat/LICENSE.txt diff --git a/scijava-legacy/README.md b/scijava-ops-compat/README.md similarity index 100% rename from scijava-legacy/README.md rename to scijava-ops-compat/README.md diff --git a/scijava-legacy/pom.xml b/scijava-ops-compat/pom.xml similarity index 91% rename from scijava-legacy/pom.xml rename to scijava-ops-compat/pom.xml index ca67d329a..c322f97ad 100644 --- a/scijava-legacy/pom.xml +++ b/scijava-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,7 +79,7 @@ - org.scijava.legacy + org.scijava.ops.compat bsd_2 SciJava developers. @@ -101,6 +101,13 @@ + org.scijava scijava-discovery @@ -111,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 @@ -159,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/scijava-ops-compat/src/main/java/module-info.java similarity index 87% rename from scijava-legacy/src/main/java/module-info.java rename to scijava-ops-compat/src/main/java/module-info.java index 29b742ed4..b6d5d1f77 100644 --- a/scijava-legacy/src/main/java/module-info.java +++ b/scijava-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/scijava-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 scijava-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/scijava-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/scijava-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 scijava-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/scijava-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/scijava-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 scijava-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/scijava-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/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor b/scijava-ops-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor similarity index 100% rename from scijava-legacy/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor rename to scijava-ops-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor diff --git a/scijava-legacy/src/test/java/org/scijava/legacy/module/process/OpEnvironmentPreprocessorTest.java b/scijava-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 scijava-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/scijava-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/scijava-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 scijava-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/scijava-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-swing/pom.xml b/scijava-swing/pom.xml index c06d4b2cc..a18270c5c 100644 --- a/scijava-swing/pom.xml +++ b/scijava-swing/pom.xml @@ -162,7 +162,7 @@ From 1d8efbd69e9d842db2d7f3ab2918c0662153d6b8 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 18 Sep 2026 11:40:14 -0500 Subject: [PATCH 48/59] migration.md: say what the presentation metadata actually does The menu row claimed the menu tree was still planned; it has been built for some time. And the UIDetails row claimed all of the presentation metadata survives, which is true of the metadata and not of the behavior: `selectable` and `selectionGroup` reach the annotation index and stop there, no binding rendering a checkbox or a radio item yet. `isSelected` and `isEnabled` are not migrated at all, and not by oversight: in SciJava Common they are *setters on the description*, the same mutability that made ModuleInfo per-instance and defeated indexing. They are application state rather than metadata, and the row now says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NShAz21gbEHnGzhVnC47cb --- docs/migration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index afac27f9a..a0498a694 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -86,7 +86,7 @@ A **dropped** row is a successful outcome, not a gap. | `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 (layer 1). All of it survives — menu path, label, icon, accelerator, weight, selection group, visibility, enablement — but as attributes in the annotation index rather than an inheritance chain every plugin extends | 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 | @@ -130,7 +130,7 @@ A **dropped** row is a successful outcome, not a gap. | `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`, `menu` | `scijava-ui3` (toolkit-agnostic contracts) and `scijava-menu` (the menu tree), with `ApplicationFrame`/`Desktop`/`StatusBar`/`ToolBar` as contracts there and 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 | +| `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 | From 3a2aa7e3ec69cf3bfd29aabac4c7effd874737cf Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 18 Sep 2026 12:07:45 -0500 Subject: [PATCH 49/59] scijava-cli: run the same commands without a display 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 names it -- menu path, label, or the tail of a class name -- matched forgivingly 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 instead of guessing. `--option value` pairs are the inputs, arriving as strings that scijava-convert3 turns into whatever the parameters declared -- the same path a script header and a text field take, which is why the command line needed no conversion code of its own. --help and --list read the same metadata the menus and dialogs read; nothing describes a command twice. 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 installs none. A command that cannot get an input it needs is declined, with a reason, through the mechanism a dismissed dialog already uses. Not ported: ConsoleService and ConsoleArgument, whose pluggable-argument mechanism earns its place when a component actually needs to add an argument -- until then a forty-line parser does. MainService has no equivalent either: main is a method. Also fixes a hang in the JavaFX binding, found when this machine had no DISPLAY today. FxThread.start() treated IllegalStateException as "somebody else started the toolkit", but JavaFX throws the same exception for "startup called twice" -- so after a failed first start, the second call declared success and every runLater afterwards waited on a latch nothing would count down. The build did not fail; it hung, which is worse. A failure is now remembered and rethrown, and the startup wait is bounded. The wait for *work* deliberately keeps no timeout: a modal dialog lives inside it for as long as the user takes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NShAz21gbEHnGzhVnC47cb --- docs/migration-plan.md | 35 +++ docs/migration.md | 4 +- pom.xml | 1 + scijava-cli/pom.xml | 235 ++++++++++++++ scijava-cli/src/main/java/module-info.java | 47 +++ .../main/java/org/scijava/cli/Arguments.java | 119 +++++++ .../src/main/java/org/scijava/cli/Cli.java | 295 ++++++++++++++++++ .../java/org/scijava/cli/CommandLookup.java | 148 +++++++++ .../java/org/scijava/cli/CliCommands.java | 150 +++++++++ .../test/java/org/scijava/cli/CliTest.java | 238 ++++++++++++++ .../java/org/scijava/javafx/FxThread.java | 58 +++- 11 files changed, 1318 insertions(+), 12 deletions(-) create mode 100644 scijava-cli/pom.xml create mode 100644 scijava-cli/src/main/java/module-info.java create mode 100644 scijava-cli/src/main/java/org/scijava/cli/Arguments.java create mode 100644 scijava-cli/src/main/java/org/scijava/cli/Cli.java create mode 100644 scijava-cli/src/main/java/org/scijava/cli/CommandLookup.java create mode 100644 scijava-cli/src/test/java/org/scijava/cli/CliCommands.java create mode 100644 scijava-cli/src/test/java/org/scijava/cli/CliTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 28f84d402..6a3e3d45b 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -969,6 +969,41 @@ widgets and watching its callbacks fire. 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. + ### Phase 4 — UI and desktop - **`scijava-harvest`** (`org.scijava.harvest`): the model behind a parameter diff --git a/docs/migration.md b/docs/migration.md index a0498a694..98a2dd2e5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -134,7 +134,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | single CLI module (Phase 4) | planned | -| `startup` | standalone module (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/pom.xml b/pom.xml index f877ac8c0..b7e2e9997 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,7 @@ scijava-awt scijava-bridge scijava-compat + scijava-cli scijava-collections scijava-command3 scijava-common3 diff --git a/scijava-cli/pom.xml b/scijava-cli/pom.xml new file mode 100644 index 000000000..a9f4906ad --- /dev/null +++ b/scijava-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/scijava-cli/src/main/java/module-info.java b/scijava-cli/src/main/java/module-info.java new file mode 100644 index 000000000..fa0a8c787 --- /dev/null +++ b/scijava-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/scijava-cli/src/main/java/org/scijava/cli/Arguments.java b/scijava-cli/src/main/java/org/scijava/cli/Arguments.java new file mode 100644 index 000000000..8c6ee89f6 --- /dev/null +++ b/scijava-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/scijava-cli/src/main/java/org/scijava/cli/Cli.java b/scijava-cli/src/main/java/org/scijava/cli/Cli.java new file mode 100644 index 000000000..2dd72e76f --- /dev/null +++ b/scijava-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/scijava-cli/src/main/java/org/scijava/cli/CommandLookup.java b/scijava-cli/src/main/java/org/scijava/cli/CommandLookup.java new file mode 100644 index 000000000..7e1b037fd --- /dev/null +++ b/scijava-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/scijava-cli/src/test/java/org/scijava/cli/CliCommands.java b/scijava-cli/src/test/java/org/scijava/cli/CliCommands.java new file mode 100644 index 000000000..07f606f4f --- /dev/null +++ b/scijava-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/scijava-cli/src/test/java/org/scijava/cli/CliTest.java b/scijava-cli/src/test/java/org/scijava/cli/CliTest.java new file mode 100644 index 000000000..833225c8a --- /dev/null +++ b/scijava-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-javafx/src/main/java/org/scijava/javafx/FxThread.java b/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java index d8c9c0c42..eca470690 100644 --- a/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java +++ b/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java @@ -49,7 +49,11 @@ */ 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 @@ -67,19 +71,37 @@ private FxThread() { */ public static synchronized void start() { if (started) return; - final CountDownLatch ready = new CountDownLatch(1); + // 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 { - Platform.startup(ready::countDown); + 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 IllegalStateException exc) { - // NB: already running, which is fine - somebody else started it. - ready.countDown(); + catch (final RuntimeException | Error exc) { + failed = true; + throw exc; } - await(ready); - // 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; } /** Runs the given work on the JavaFX thread, and waits for it to finish. */ @@ -114,6 +136,9 @@ public static T get(final Supplier work) { 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(); @@ -130,4 +155,17 @@ private static void await(final CountDownLatch latch) { 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); + } + } } From eccf33eec0ad36ffdd1b43c94e76a6084dcc7bb5 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 18 Sep 2026 20:20:15 -0500 Subject: [PATCH 50/59] scijava-settings: one TOML file a person can read and edit Settings live at ~/.config//settings.toml on every platform, 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, and two applications sharing a JVM want two of them. That is the line this codebase had been drawing without saying it: static facades for stateless capabilities -- Converters, Locations, Scripts, since converting a string to an int does not depend on which application you are -- and scoped objects for anything with identity or lifetime. A static settings store would have quietly spent the multiple-contexts promise, and then the honest question would be why there is a container at all. A table per command, keyed by its identifier rather than by its component's coordinates: a class moving between artifacts would otherwise lose what the user chose, which is a refactor nobody should be punished for. Values map onto TOML's own types by a small hardcoded table with string as the fallback, so a List is an array of strings rather than one mangled string, and reading converts element-wise through convert3. Widgets need no serialization API for this: serialization is a property of the type, and a widget is a view -- which is also why it needs no answer per toolkit. Comments survive a save. The file is one people edit, and a store that ate the "# 0.7 because the detector is noisy" beside a value is one they would stop editing. The TOML is parsed here: tables, all four string forms, integers in four bases, floats, booleans, all four date-and-time types, arrays -- with inline tables and arrays of tables refused by line number rather than misunderstood. Neither candidate library is modular, and an automatic module in a foundation component is the trade that dropped MigLayout; the API is small enough that tomlj can take its place later. Persistence is LoadInputs and SaveInputs, both preprocessors, so a caller can run a command without persistence interfering. Saving before the run rather than after is Curtis's call and a good one: saving afterwards loses the values whenever the run threw, which is exactly when the user is about to try again. There is a test that fails under the other design. Two things fell out below it: - Execution.isSupplied(key), because "did the caller pass this?" is not "is this parameter empty?" -- a double is never empty, so loading by null-check would never fill a primitive, and overwriting regardless would make a remembered value beat an explicit one. SciJava Common used a mutable `resolved` flag per parameter; the run knows what it was given, so it is asked instead. - A convert3 bug: CastConverter saw a List bound for List, called it already the right type, and returned a List untouched -- the right container full of the wrong things. It now declines a parameterized collection and lets the element-wise converter have it. The earlier tests missed this because they all converted arrays to lists, where the raw types differ. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NShAz21gbEHnGzhVnC47cb --- docs/migration-plan.md | 61 ++ docs/migration.md | 4 +- pom.xml | 1 + .../org/scijava/convert3/CastConverter.java | 22 +- .../org/scijava/convert3/ConvertersTest.java | 21 + .../java/org/scijava/execute/Execution.java | 16 + .../scijava/execute/FieldParameterMember.java | 2 + .../java/org/scijava/execute/Parameter.java | 20 + .../org/scijava/execute/ParameterMember.java | 6 + .../main/java/org/scijava/execute/Runner.java | 14 +- scijava-settings/pom.xml | 182 +++++ .../src/main/java/module-info.java | 41 ++ .../java/org/scijava/settings/LoadInputs.java | 82 +++ .../scijava/settings/RememberedInputs.java | 104 +++ .../java/org/scijava/settings/SaveInputs.java | 80 +++ .../java/org/scijava/settings/Settings.java | 354 ++++++++++ .../main/java/org/scijava/settings/Toml.java | 645 ++++++++++++++++++ .../org/scijava/settings/TomlException.java | 56 ++ .../settings/RememberedInputsTest.java | 191 ++++++ .../org/scijava/settings/SettingsTest.java | 193 ++++++ .../java/org/scijava/settings/TomlTest.java | 213 ++++++ 21 files changed, 2303 insertions(+), 5 deletions(-) create mode 100644 scijava-settings/pom.xml create mode 100644 scijava-settings/src/main/java/module-info.java create mode 100644 scijava-settings/src/main/java/org/scijava/settings/LoadInputs.java create mode 100644 scijava-settings/src/main/java/org/scijava/settings/RememberedInputs.java create mode 100644 scijava-settings/src/main/java/org/scijava/settings/SaveInputs.java create mode 100644 scijava-settings/src/main/java/org/scijava/settings/Settings.java create mode 100644 scijava-settings/src/main/java/org/scijava/settings/Toml.java create mode 100644 scijava-settings/src/main/java/org/scijava/settings/TomlException.java create mode 100644 scijava-settings/src/test/java/org/scijava/settings/RememberedInputsTest.java create mode 100644 scijava-settings/src/test/java/org/scijava/settings/SettingsTest.java create mode 100644 scijava-settings/src/test/java/org/scijava/settings/TomlTest.java diff --git a/docs/migration-plan.md b/docs/migration-plan.md index 6a3e3d45b..5ccc346ba 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -1004,6 +1004,67 @@ scijava --help "Gaussian Blur" 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 diff --git a/docs/migration.md b/docs/migration.md index 98a2dd2e5..a8ac5d3e1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -47,7 +47,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | `scijava-context` prefs service (Phase 2); already deprecated in SJC | planned | +| `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 | @@ -117,7 +117,7 @@ A **dropped** row is a successful outcome, not a gap. | `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` | split: a TOML settings store under `~/.config/fiji` (design settled in [migration-plan.md](migration-plan.md)), and widget value persistence, which belongs with the input harvester (Phase 4). Explicitly **not** `java.util.prefs` | 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 | diff --git a/pom.xml b/pom.xml index b7e2e9997..49afdf6b2 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,7 @@ scijava-priority scijava-progress scijava-script3 + scijava-settings scijava-spi scijava-struct scijava-swing diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java b/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java index cebaeabde..ec2f8f0aa 100644 --- a/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java +++ b/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java @@ -29,7 +29,9 @@ 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; @@ -62,7 +64,25 @@ 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; - return Classes.box(raw).isInstance(source); + 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 diff --git a/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java b/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java index 9c5c73dee..fe560260f 100644 --- a/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java +++ b/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java @@ -141,6 +141,27 @@ public void testCollections() { 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() { diff --git a/scijava-execute/src/main/java/org/scijava/execute/Execution.java b/scijava-execute/src/main/java/org/scijava/execute/Execution.java index d410cd6ff..25c22d4f3 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Execution.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Execution.java @@ -75,6 +75,22 @@ public interface Execution { /** 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. diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java index 96f70e745..52cd2f7bf 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -139,6 +139,8 @@ public Map attrs() { 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; } diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java index 044ae2495..eed71a9c8 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Parameter.java @@ -74,6 +74,26 @@ /** 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 ""; diff --git a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java index 55e721e9e..15aeef7fb 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java +++ b/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java @@ -87,6 +87,12 @@ public interface ParameterMember extends Member { /** 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"; diff --git a/scijava-execute/src/main/java/org/scijava/execute/Runner.java b/scijava-execute/src/main/java/org/scijava/execute/Runner.java index 3d7d63962..8dba94131 100644 --- a/scijava-execute/src/main/java/org/scijava/execute/Runner.java +++ b/scijava-execute/src/main/java/org/scijava/execute/Runner.java @@ -33,6 +33,7 @@ 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; @@ -137,7 +138,7 @@ private ExecutionResult execute(final Executable executable, { final ExecutableInstance instance = executable.create(); final DefaultExecution execution = new DefaultExecution(instance, // - Executables.bind(instance.parameters(), inputs, false)); + Executables.bind(instance.parameters(), inputs, false), inputs.keySet()); for (final Preprocessor preprocessor : preprocessors) { Executables.checkCancellation(); @@ -203,11 +204,20 @@ private static class DefaultExecution implements Execution { 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 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) { diff --git a/scijava-settings/pom.xml b/scijava-settings/pom.xml new file mode 100644 index 000000000..5c7ac51ab --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/module-info.java b/scijava-settings/src/main/java/module-info.java new file mode 100644 index 000000000..98d796b42 --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/org/scijava/settings/LoadInputs.java b/scijava-settings/src/main/java/org/scijava/settings/LoadInputs.java new file mode 100644 index 000000000..f61f85b48 --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/org/scijava/settings/RememberedInputs.java b/scijava-settings/src/main/java/org/scijava/settings/RememberedInputs.java new file mode 100644 index 000000000..c2d2879cb --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/org/scijava/settings/SaveInputs.java b/scijava-settings/src/main/java/org/scijava/settings/SaveInputs.java new file mode 100644 index 000000000..96b1a7088 --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/org/scijava/settings/Settings.java b/scijava-settings/src/main/java/org/scijava/settings/Settings.java new file mode 100644 index 000000000..cd8f78bdc --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/org/scijava/settings/Toml.java b/scijava-settings/src/main/java/org/scijava/settings/Toml.java new file mode 100644 index 000000000..2ab96e8c8 --- /dev/null +++ b/scijava-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/scijava-settings/src/main/java/org/scijava/settings/TomlException.java b/scijava-settings/src/main/java/org/scijava/settings/TomlException.java new file mode 100644 index 000000000..73c6c9f87 --- /dev/null +++ b/scijava-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/scijava-settings/src/test/java/org/scijava/settings/RememberedInputsTest.java b/scijava-settings/src/test/java/org/scijava/settings/RememberedInputsTest.java new file mode 100644 index 000000000..02c6e5294 --- /dev/null +++ b/scijava-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/scijava-settings/src/test/java/org/scijava/settings/SettingsTest.java b/scijava-settings/src/test/java/org/scijava/settings/SettingsTest.java new file mode 100644 index 000000000..e083cc876 --- /dev/null +++ b/scijava-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/scijava-settings/src/test/java/org/scijava/settings/TomlTest.java b/scijava-settings/src/test/java/org/scijava/settings/TomlTest.java new file mode 100644 index 000000000..9e66f8d40 --- /dev/null +++ b/scijava-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())); + } +} From 7f4e5fa7e248523e366d933b2fa80655ae37c0ce Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 20 Sep 2026 18:47:21 -0500 Subject: [PATCH 51/59] Shorten submodule folder names Submodules of this repository will always be prefixed with `scijava-`; including it in the folder names is redundant. --- {scijava-awt => awt}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/awt/AwtChoice.java | 0 .../main/java/org/scijava/awt/AwtDialog.java | 0 .../main/java/org/scijava/awt/AwtFile.java | 0 .../org/scijava/awt/AwtInputHarvester.java | 0 .../main/java/org/scijava/awt/AwtMenus.java | 0 .../main/java/org/scijava/awt/AwtMessage.java | 0 .../main/java/org/scijava/awt/AwtNumber.java | 0 .../main/java/org/scijava/awt/AwtPanel.java | 0 .../main/java/org/scijava/awt/AwtText.java | 0 .../main/java/org/scijava/awt/AwtToggle.java | 0 .../main/java/org/scijava/awt/AwtWidget.java | 0 .../org/scijava/awt/AwtWidgetFactory.java | 0 .../src/main/java/org/scijava/awt/Edt.java | 0 .../java/org/scijava/awt/AwtMenusTest.java | 0 .../test/java/org/scijava/awt/AwtShell.java | 0 .../java/org/scijava/awt/AwtWidgetsTest.java | 0 .../scripts/Process/Filters/Blur_It.groovy | 0 .../test/resources/scripts/Say_Hello.groovy | 0 {scijava-cli => cli}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/cli/Arguments.java | 0 .../src/main/java/org/scijava/cli/Cli.java | 0 .../java/org/scijava/cli/CommandLookup.java | 0 .../java/org/scijava/cli/CliCommands.java | 0 .../test/java/org/scijava/cli/CliTest.java | 0 .../.gitignore | 0 .../LICENSE.txt | 0 .../README.md | 0 {scijava-collections => collections}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../collections/AbstractPrimitiveArray.java | 0 .../org/scijava/collections/BoolArray.java | 0 .../org/scijava/collections/ByteArray.java | 0 .../org/scijava/collections/CharArray.java | 0 .../org/scijava/collections/DoubleArray.java | 0 .../org/scijava/collections/FloatArray.java | 0 .../org/scijava/collections/IntArray.java | 0 .../org/scijava/collections/LongArray.java | 0 .../org/scijava/collections/ObjectArray.java | 0 .../scijava/collections/PrimitiveArray.java | 0 .../org/scijava/collections/ShortArray.java | 0 .../scijava/collections/BoolArrayTest.java | 0 .../scijava/collections/ByteArrayTest.java | 0 .../scijava/collections/CharArrayTest.java | 0 .../scijava/collections/DoubleArrayTest.java | 0 .../scijava/collections/FloatArrayTest.java | 0 .../org/scijava/collections/IntArrayTest.java | 0 .../scijava/collections/LongArrayTest.java | 0 .../scijava/collections/ObjectArrayTest.java | 0 .../collections/PrimitiveArrayTest.java | 0 .../scijava/collections/ShortArrayTest.java | 0 {scijava-command3 => command3}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/command3/Accelerator.java | 0 .../scijava/command3/ClassCommandInfo.java | 0 .../java/org/scijava/command3/Command.java | 0 .../org/scijava/command3/CommandInfo.java | 0 .../java/org/scijava/command3/Commands.java | 0 .../main/java/org/scijava/command3/Menu.java | 0 .../org/scijava/command3/MenuCreator.java | 0 .../java/org/scijava/command3/MenuTree.java | 0 .../main/java/org/scijava/command3/Menus.java | 0 .../org/scijava/command3/CommandsTest.java | 0 .../java/org/scijava/command3/CountBeans.java | 0 .../org/scijava/command3/DeepCommand.java | 0 .../java/org/scijava/command3/Headless.java | 0 .../org/scijava/command3/HiddenCommand.java | 0 .../org/scijava/command3/MenuTreeTest.java | 0 .../java/org/scijava/command3/NeverRun.java | 0 .../java/org/scijava/command3/SayHello.java | 0 {scijava-common3 => common3}/.gitignore | 0 {scijava-common3 => common3}/LICENSE.txt | 0 {scijava-common3 => common3}/README.md | 0 {scijava-common3 => common3}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/common3/Annotations.java | 0 .../main/java/org/scijava/common3/Any.java | 0 .../main/java/org/scijava/common3/Apps.java | 0 .../java/org/scijava/common3/Classes.java | 0 .../java/org/scijava/common3/Comparisons.java | 0 .../java/org/scijava/common3/Digests.java | 0 .../java/org/scijava/common3/FilePaths.java | 0 .../scijava/common3/MersenneTwisterFast.java | 0 .../java/org/scijava/common3/Numbers.java | 0 .../java/org/scijava/common3/Platforms.java | 0 .../java/org/scijava/common3/Processes.java | 0 .../java/org/scijava/common3/Strings.java | 0 .../java/org/scijava/common3/Threads.java | 0 .../main/java/org/scijava/common3/Types.java | 0 .../main/java/org/scijava/common3/URLs.java | 0 .../java/org/scijava/common3/ClassesTest.java | 0 .../scijava/common3/CommonSuperTypeTest.java | 0 .../java/org/scijava/common3/DigestsTest.java | 0 .../org/scijava/common3/FilePathsTest.java | 0 .../java/org/scijava/common3/NumbersTest.java | 0 .../org/scijava/common3/ProcessesTest.java | 0 .../java/org/scijava/common3/StringsTest.java | 0 .../java/org/scijava/common3/TypesTest.java | 0 {scijava-compat => compat}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/compat/Legacy.java | 0 .../org/scijava/compat/LegacyCommandInfo.java | 0 .../org/scijava/compat/LegacyContext.java | 0 .../java/org/scijava/compat/LegacyMember.java | 0 .../org/scijava/compat/LegacyBridgeTest.java | 0 .../org/scijava/compat/LegacyCommands.java | 0 .../org/scijava/compat/LegacyDialogTest.java | 0 .../LICENSE.txt | 0 {scijava-concurrent => concurrent}/README.md | 0 {scijava-concurrent => concurrent}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../scijava/concurrent/Parallelization.java | 0 .../org/scijava/concurrent/TaskExecutor.java | 0 .../org/scijava/concurrent/TaskExecutors.java | 0 .../scijava/concurrent/CancellationTest.java | 0 .../concurrent/DefaultTaskExecutorTest.java | 0 .../concurrent/ParallelizationBenchmark.java | 0 .../concurrent/ParallelizationDemo.java | 0 .../concurrent/ParallelizationTest.java | 0 .../scijava/concurrent/TaskExecutorsTest.java | 0 .../org/scijava/concurrent/package-info.java | 0 .../pom.xml | 0 .../src/main/java/module-info.java | 0 .../scijava/context/test/GreeterService.java | 0 .../context/test/RecordingService.java | 0 .../scijava/context/test/ShoutRecorded.java | 0 .../org/scijava/context/test/Shouter.java | 0 .../test/impl/DefaultRecordingService.java | 0 .../context/test/impl/HiddenGreeter.java | 0 .../context/test/impl/InjectedShouter.java | 0 .../context/test/impl/LoudShouter.java | 0 .../context/test/impl/QuietShouter.java | 0 .../context/test/EventHandlerTest.java | 0 .../scijava/context/test/InjectionTest.java | 0 .../context/test/PluginDiscoveryTest.java | 0 .../context/test/ServiceDiscoveryTest.java | 0 {scijava-context => context}/.factorypath | 0 {scijava-context => context}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/context/Access.java | 0 .../main/java/org/scijava/context/Attr.java | 0 .../java/org/scijava/context/Context.java | 0 .../java/org/scijava/context/Dependency.java | 0 .../org/scijava/context/EventHandler.java | 0 .../context/NoSuchServiceException.java | 0 .../main/java/org/scijava/context/Plugin.java | 0 .../java/org/scijava/context/Service.java | 0 .../org/scijava/context/ServiceException.java | 0 .../java/org/scijava/context/ContextTest.java | 0 {scijava-convert3 => convert3}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/convert3/ArrayConverter.java | 0 .../org/scijava/convert3/CastConverter.java | 0 .../scijava/convert3/CollectionConverter.java | 0 .../convert3/ConstructorConverter.java | 0 .../scijava/convert3/ConversionException.java | 0 .../java/org/scijava/convert3/Converter.java | 0 .../java/org/scijava/convert3/Converters.java | 0 .../org/scijava/convert3/NumberConverter.java | 0 .../org/scijava/convert3/PathConverters.java | 0 .../org/scijava/convert3/StringConverter.java | 0 .../scijava/convert3/ToStringConverter.java | 0 .../services/org.scijava.convert3.Converter | 0 .../org/scijava/convert3/ConvertersTest.java | 0 .../.gitignore | 0 .../LICENSE.txt | 0 .../README.md | 0 .../pom.xml | 0 .../src/main/java/module-info.java | 0 .../discovery/test/ServiceBasedAdder.java | 0 .../test/ServiceBasedMultipliers.java | 0 .../META-INF/services/org.scijava.ops.spi.Op | 0 .../services/org.scijava.ops.spi.OpCollection | 0 .../discovery/test/DiscoveryLazinessTest.java | 0 .../test/ServiceLoaderDiscovererTest.java | 0 {scijava-discovery => discovery}/.gitignore | 0 {scijava-discovery => discovery}/LICENSE.txt | 0 {scijava-discovery => discovery}/README.md | 0 {scijava-discovery => discovery}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/discovery/Discoverer.java | 0 .../java/org/scijava/discovery/Discovery.java | 0 .../scijava/discovery/ManualDiscoverer.java | 0 {scijava-events => events}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/events/Consumable.java | 0 .../org/scijava/events/DefaultEventBus.java | 0 .../java/org/scijava/events/EventBus.java | 0 .../events/EventDeliveryException.java | 0 .../java/org/scijava/events/Subscription.java | 0 .../java/org/scijava/events/EventBusTest.java | 0 {scijava-execute => execute}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/execute/Behavior.java | 0 .../scijava/execute/BehaviorException.java | 0 .../java/org/scijava/execute/Executable.java | 0 .../scijava/execute/ExecutableInstance.java | 0 .../java/org/scijava/execute/Executables.java | 0 .../java/org/scijava/execute/Execution.java | 0 .../org/scijava/execute/ExecutionResult.java | 0 .../scijava/execute/FieldParameterMember.java | 0 .../execute/FieldParameterMemberParser.java | 0 .../org/scijava/execute/JavaExecutable.java | 0 .../java/org/scijava/execute/Parameter.java | 0 .../org/scijava/execute/ParameterMember.java | 0 .../java/org/scijava/execute/Parameters.java | 0 .../org/scijava/execute/Postprocessor.java | 0 .../org/scijava/execute/Preprocessor.java | 0 .../main/java/org/scijava/execute/Runner.java | 0 .../org/scijava/execute/ExecutablesTest.java | 0 .../java/org/scijava/execute/RunnerTest.java | 0 .../execute/ScriptLikeExecutableTest.java | 0 {scijava-function => function}/LICENSE.txt | 0 {scijava-function => function}/README.md | 0 .../bin/generate.groovy | 0 {scijava-function => function}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/function/Computers.java | 0 .../java/org/scijava/function/Consumers.java | 0 .../java/org/scijava/function/Container.java | 0 .../java/org/scijava/function/Functions.java | 0 .../java/org/scijava/function/Inplaces.java | 0 .../java/org/scijava/function/Mutable.java | 0 .../java/org/scijava/function/Producer.java | 0 .../org/scijava/function/package-info.java | 0 .../java/org/scijava/function/Computers.list | 0 .../java/org/scijava/function/Computers.vm | 0 .../java/org/scijava/function/Consumers.list | 0 .../java/org/scijava/function/Consumers.vm | 0 .../java/org/scijava/function/Functions.list | 0 .../java/org/scijava/function/Functions.vm | 0 .../java/org/scijava/function/Globals.list | 0 .../java/org/scijava/function/Inplaces.list | 0 .../java/org/scijava/function/Inplaces.vm | 0 {scijava-harvest => harvest}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/harvest/Group.java | 0 .../main/java/org/scijava/harvest/Groups.java | 0 .../org/scijava/harvest/ObservableStruct.java | 0 .../org/scijava/harvest/ParameterModel.java | 0 .../org/scijava/harvest/ParameterNode.java | 0 .../org/scijava/harvest/ParameterTree.java | 0 .../scijava/harvest/ParameterModelTest.java | 0 .../scijava/harvest/ParameterTreeTest.java | 0 .../pom.xml | 0 .../src/main/java/module-info.java | 0 .../imagej/compat/DatasetTypeExtractor.java | 0 .../compat/DatasetTypeExtractorTest.java | 0 {scijava-index => index}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../scijava/index/AbstractIndexWriter.java | 0 .../scijava/index/AnnotationProcessor.java | 0 .../org/scijava/index/ByteCodeAnalyzer.java | 0 .../org/scijava/index/DirectoryIndexer.java | 0 .../main/java/org/scijava/index/Index.java | 0 .../org/scijava/index/IndexDiscoverer.java | 0 .../java/org/scijava/index/IndexItem.java | 0 .../java/org/scijava/index/IndexReader.java | 0 .../java/org/scijava/index/Indexable.java | 0 .../javax.annotation.processing.Processor | 0 .../index/AnnotationProcessorTest.java | 0 .../test/java/org/scijava/index/Circle.java | 0 .../test/java/org/scijava/index/Color.java | 0 .../scijava/index/IndexDiscovererTest.java | 0 .../src/test/java/org/scijava/index/Red.java | 0 .../test/java/org/scijava/index/Shape.java | 0 .../test/java/org/scijava/index/Square.java | 0 .../test/java/org/scijava/index/Widget.java | 0 {scijava-io3 => io3}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/io3/ByteArrayByteBank.java | 0 .../main/java/org/scijava/io3/ByteBank.java | 0 .../org/scijava/io3/ByteBufferByteBank.java | 0 .../src/main/java/org/scijava/io3/Bytes.java | 0 .../io3/handle/AbstractDataHandle.java | 0 .../io3/handle/AbstractHigherOrderHandle.java | 0 .../handle/AbstractSeekableStreamHandle.java | 0 .../io3/handle/AbstractStreamHandle.java | 0 .../org/scijava/io3/handle/BytesHandle.java | 0 .../org/scijava/io3/handle/DataHandle.java | 0 .../io3/handle/DataHandleInputStream.java | 0 .../io3/handle/DataHandleOutputStream.java | 0 .../org/scijava/io3/handle/DataHandles.java | 0 .../org/scijava/io3/handle/DummyHandle.java | 0 .../org/scijava/io3/handle/FileHandle.java | 0 .../io3/handle/ReadBufferDataHandle.java | 0 .../io3/handle/ResettableStreamHandle.java | 0 .../io3/handle/SeekableStreamHandle.java | 0 .../org/scijava/io3/handle/StreamHandle.java | 0 .../io3/handle/WriteBufferDataHandle.java | 0 .../io3/location/AbstractLocation.java | 0 .../location/AbstractLocationResolver.java | 0 .../io3/location/AbstractRemoteLocation.java | 0 .../io3/location/BrowsableLocation.java | 0 .../scijava/io3/location/BytesLocation.java | 0 .../scijava/io3/location/DummyLocation.java | 0 .../scijava/io3/location/FileLocation.java | 0 .../io3/location/FileLocationResolver.java | 0 .../org/scijava/io3/location/Location.java | 0 .../io3/location/LocationResolver.java | 0 .../org/scijava/io3/location/Locations.java | 0 .../scijava/io3/location/RemoteLocation.java | 0 .../org/scijava/io3/location/URILocation.java | 0 .../org/scijava/io3/location/URLLocation.java | 0 .../org.scijava.io3.location.LocationResolver | 0 .../scijava/io3/ByteBufferByteBankTest.java | 0 .../scijava/io3/handle/DataHandlesTest.java | 0 .../scijava/io3/location/LocationsTest.java | 0 {scijava-javafx => javafx}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/javafx/FxChoice.java | 0 .../java/org/scijava/javafx/FxDialog.java | 0 .../main/java/org/scijava/javafx/FxFile.java | 0 .../org/scijava/javafx/FxInputHarvester.java | 0 .../main/java/org/scijava/javafx/FxMenus.java | 0 .../java/org/scijava/javafx/FxMessage.java | 0 .../java/org/scijava/javafx/FxNumber.java | 0 .../main/java/org/scijava/javafx/FxPanel.java | 0 .../main/java/org/scijava/javafx/FxText.java | 0 .../java/org/scijava/javafx/FxThread.java | 0 .../java/org/scijava/javafx/FxToggle.java | 0 .../java/org/scijava/javafx/FxWidget.java | 0 .../org/scijava/javafx/FxWidgetFactory.java | 0 .../java/org/scijava/javafx/FxMenusTest.java | 0 .../test/java/org/scijava/javafx/FxShell.java | 0 .../org/scijava/javafx/FxWidgetsTest.java | 0 .../scripts/Process/Filters/Blur_It.groovy | 0 .../test/resources/scripts/Say_Hello.groovy | 0 {scijava-meta => meta}/.gitignore | 0 {scijava-meta => meta}/LICENSE.txt | 0 {scijava-meta => meta}/README.md | 0 {scijava-meta => meta}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/meta/Manifest.java | 0 .../src/main/java/org/scijava/meta/POM.java | 0 .../main/java/org/scijava/meta/Versions.java | 0 .../src/main/java/org/scijava/meta/XML.java | 0 .../test/java/org/scijava/meta/POMTest.java | 0 .../java/org/scijava/meta/VersionsTest.java | 0 {scijava-ops-api => ops-api}/.gitignore | 0 {scijava-ops-api => ops-api}/LICENSE.txt | 0 {scijava-ops-api => ops-api}/README.md | 0 .../bin/generate.groovy | 0 {scijava-ops-api => ops-api}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/ops/api/Hints.java | 0 .../java/org/scijava/ops/api/InfoTree.java | 0 .../java/org/scijava/ops/api/OpBuilder.java | 0 .../org/scijava/ops/api/OpEnvironment.java | 0 .../java/org/scijava/ops/api/OpHistory.java | 0 .../main/java/org/scijava/ops/api/OpInfo.java | 0 .../java/org/scijava/ops/api/OpInstance.java | 0 .../scijava/ops/api/OpMatchingException.java | 0 .../java/org/scijava/ops/api/OpRequest.java | 0 .../main/java/org/scijava/ops/api/Ops.java | 0 .../main/java/org/scijava/ops/api/RichOp.java | 0 .../org/scijava/ops/api/package-info.java | 0 .../java/org/scijava/ops/api/Globals.list | 0 .../java/org/scijava/ops/api/OpBuilder.list | 0 .../java/org/scijava/ops/api/OpBuilder.vm | 0 .../.gitignore | 0 .../LICENSE.txt | 0 .../README.md | 0 .../pom.xml | 0 .../ops/benchmarks/BenchmarkState.java | 0 .../scijava/ops/benchmarks/Benchmarks.java | 0 .../ops/benchmarks/OpsToBenchmark.java | 0 .../matching/OpsToBenchmarkTest.java | 0 .../LICENSE.txt | 0 {scijava-ops-compat => ops-compat}/README.md | 0 {scijava-ops-compat => ops-compat}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../compat/DefaultOpEnvironmentService.java | 0 .../OpEnvironmentPreprocessorPlugin.java | 0 .../ops/compat/OpEnvironmentService.java | 0 .../org.scijava.types.extract.TypeExtractor | 0 .../compat/OpEnvironmentPreprocessorTest.java | 0 .../ops/compat/OpEnvironmentServiceTest.java | 0 {scijava-ops-engine => ops-engine}/.gitignore | 0 .../LICENSE.txt | 0 {scijava-ops-engine => ops-engine}/README.md | 0 .../bin/generate.groovy | 0 {scijava-ops-engine => ops-engine}/pom.xml | 0 .../src/it/settings.xml | 0 .../src/it/test-ops-api/pom.xml | 0 .../src/main/java/OpsAPITest.java | 0 .../src/main/java/module-info.java | 0 .../org/scijava/ops/engine/BaseOpHints.java | 0 .../engine/DependencyMatchingException.java | 0 .../scijava/ops/engine/InfoTreeGenerator.java | 0 .../ops/engine/MatchingConditions.java | 0 .../ops/engine/OpDependencyMember.java | 0 .../ops/engine/OpDescriptionGenerator.java | 0 .../scijava/ops/engine/OpInfoGenerator.java | 0 .../org/scijava/ops/engine/OpWrapper.java | 0 .../ComputersToFunctionsAndLift.java | 0 .../FunctionsToComputersAndLift.java | 0 .../ComputersToFunctionsViaFunction.java | 0 .../ComputersToFunctionsViaSource.java | 0 .../functional/FunctionsToComputers.java | 0 .../adapt/functional/InplacesToFunctions.java | 0 .../engine/adapt/lift/ComputerToArrays.java | 0 .../adapt/lift/ComputerToIterables.java | 0 .../engine/adapt/lift/FunctionToArrays.java | 0 .../adapt/lift/FunctionToIterables.java | 0 .../engine/adapt/lift/InplaceToArrays.java | 0 .../engine/conversionLoss/LossReporter.java | 0 .../conversionLoss/LosslessReporter.java | 0 .../impl/IdentityLossReporter.java | 0 .../impl/PrimitiveArrayLossReporters.java | 0 .../impl/PrimitiveLossReporters.java | 0 .../engine/conversionLoss/package-info.java | 0 .../ops/engine/copy/CopyOpCollection.java | 0 .../ops/engine/create/CreateOpCollection.java | 0 .../ops/engine/describe/BaseDescriptors.java | 0 .../ops/engine/describe/TypeDescriptor.java | 0 .../scijava/ops/engine/eval/DefaultEval.java | 0 .../scijava/ops/engine/eval/OpEvaluator.java | 0 .../engine/exceptions/InvalidOpException.java | 0 .../impl/FinalOpDependencyFieldException.java | 0 .../impl/FunctionalTypeOpException.java | 0 .../impl/InstanceOpMethodException.java | 0 .../impl/InvalidOpNameException.java | 0 .../impl/MultipleOutputsOpException.java | 0 .../NullablesOnMultipleMethodsException.java | 0 .../impl/OpDependencyPositionException.java | 0 .../exceptions/impl/PrivateOpException.java | 0 .../exceptions/impl/UnnamedOpException.java | 0 .../impl/UnreadableOpException.java | 0 .../engine/impl/DefaultInfoTreeGenerator.java | 0 .../impl/DefaultOpDescriptionGenerator.java | 0 .../ops/engine/impl/DefaultOpEnvironment.java | 0 .../ops/engine/impl/DefaultOpHistory.java | 0 .../impl/DependencyOpInstanceInfoTree.java | 0 .../engine/impl/DependencyRichOpInfoTree.java | 0 .../ops/engine/impl/LambdaTypeBaker.java | 0 .../engine/impl/OpClassOpInfoGenerator.java | 0 .../impl/OpCollectionInfoGenerator.java | 0 .../ops/engine/matcher/MatchingResult.java | 0 .../ops/engine/matcher/MatchingRoutine.java | 0 .../ops/engine/matcher/OpCandidate.java | 0 .../scijava/ops/engine/matcher/OpMatcher.java | 0 .../adapt/AdaptationInfoTreeGenerator.java | 0 .../adapt/AdaptationMatchingRoutine.java | 0 .../matcher/adapt/OpAdaptationInfo.java | 0 .../convert/ConversionMatchingRoutine.java | 0 .../engine/matcher/convert/Conversions.java | 0 .../convert/ConvertedInfoTreeGenerator.java | 0 .../matcher/convert/ConvertedOpInfo.java | 0 .../matcher/convert/IdentityCollection.java | 0 .../convert/ListConverterCollection.java | 0 .../convert/PrimitiveArrayConverters.java | 0 .../matcher/convert/PrimitiveConverters.java | 0 .../matcher/convert/UtilityConverters.java | 0 .../engine/matcher/impl/AbstractRichOp.java | 0 .../matcher/impl/DefaultOpClassInfo.java | 0 .../matcher/impl/DefaultOpFieldInfo.java | 0 .../engine/matcher/impl/DefaultOpMatcher.java | 0 .../matcher/impl/DefaultOpMethodInfo.java | 0 .../engine/matcher/impl/DefaultOpRequest.java | 0 .../matcher/impl/InfoMatchingOpRequest.java | 0 .../matcher/impl/LossReporterWrapper.java | 0 .../engine/matcher/impl/MatchingUtils.java | 0 .../ops/engine/matcher/impl/OpWrappers.java | 0 .../impl/RuntimeSafeMatchingRoutine.java | 0 .../matcher/reduce/AbstractInfoReducer.java | 0 .../matcher/reduce/ComputerReducer.java | 0 .../matcher/reduce/FunctionReducer.java | 0 .../engine/matcher/reduce/InfoReducer.java | 0 .../reduce/ReducedInfoTreeGenerator.java | 0 .../engine/matcher/reduce/ReducedOpInfo.java | 0 .../reduce/ReducedOpInfoGenerator.java | 0 .../engine/matcher/reduce/ReductionUtils.java | 0 .../java/org/scijava/ops/engine/math/Add.java | 0 .../ops/engine/math/MathOpCollection.java | 0 .../org/scijava/ops/engine/math/MathOps.java | 0 .../scijava/ops/engine/math/Normalize.java | 0 .../org/scijava/ops/engine/math/Power.java | 0 .../org/scijava/ops/engine/math/Sqrt.java | 0 .../org/scijava/ops/engine/math/Zero.java | 0 .../org/scijava/ops/engine/package-info.java | 0 .../org/scijava/ops/engine/stats/Mean.java | 0 .../org/scijava/ops/engine/stats/Size.java | 0 .../struct/AnnotatedOpDependencyMember.java | 0 .../struct/ClassOpDependencyMemberParser.java | 0 .../struct/ClassParameterMemberParser.java | 0 .../struct/ConvertedParameterMember.java | 0 .../ops/engine/struct/FieldInstance.java | 0 .../struct/FieldOpDependencyMember.java | 0 .../struct/FieldParameterMemberParser.java | 0 .../engine/struct/FunctionalMethodType.java | 0 .../engine/struct/FunctionalParameters.java | 0 .../MethodOpDependencyMemberParser.java | 0 .../struct/MethodParameterMemberParser.java | 0 .../MethodParameterOpDependencyMember.java | 0 .../engine/struct/OpResizingMemberParser.java | 0 .../engine/struct/OpRetypingMemberParser.java | 0 .../ops/engine/struct/ParameterData.java | 0 .../ops/engine/struct/RetypingRequest.java | 0 .../struct/SynthesizedFieldParameterData.java | 0 .../SynthesizedMethodParameterData.java | 0 .../struct/SynthesizedParameterMember.java | 0 .../ops/engine/util/FunctionUtils.java | 0 .../org/scijava/ops/engine/util/Infos.java | 0 .../org/scijava/ops/engine/util/Lambdas.java | 0 .../org/scijava/ops/engine/util/Maps.java | 0 .../engine/util/internal/AnnotationUtils.java | 0 .../engine/util/internal/OpMethodUtils.java | 0 .../engine/util/internal/package-info.java | 0 .../scijava/ops/engine/util/package-info.java | 0 .../ops/engine/yaml/YAMLOpInfoCreator.java | 0 .../scijava/ops/engine/yaml/YAMLUtils.java | 0 .../engine/yaml/impl/AbstractYAMLOpInfo.java | 0 .../yaml/impl/JavaClassYAMLOpInfoCreator.java | 0 .../yaml/impl/JavaFieldYAMLOpInfoCreator.java | 0 .../yaml/impl/JavaMethodYAMLInfoCreator.java | 0 .../ops/engine/yaml/impl/YAMLOpClassInfo.java | 0 .../ops/engine/yaml/impl/YAMLOpFieldInfo.java | 0 .../yaml/impl/YAMLOpInfoDiscoverer.java | 0 .../engine/yaml/impl/YAMLOpMethodInfo.java | 0 .../services/org.scijava.discovery.Discoverer | 0 .../org.scijava.ops.api.OpEnvironment | 0 .../services/org.scijava.ops.api.OpHistory | 0 .../org.scijava.ops.engine.InfoTreeGenerator | 0 ....scijava.ops.engine.OpDescriptionGenerator | 0 .../org.scijava.ops.engine.OpInfoGenerator | 0 .../services/org.scijava.ops.engine.OpWrapper | 0 ...scijava.ops.engine.matcher.MatchingRoutine | 0 ...java.ops.engine.matcher.reduce.InfoReducer | 0 ....scijava.ops.engine.yaml.YAMLOpInfoCreator | 0 .../META-INF/services/org.scijava.ops.spi.Op | 0 .../services/org.scijava.ops.spi.OpCollection | 0 .../ops/engine/AbstractTestEnvironment.java | 0 .../ops/engine/InfoTreeGeneratorTest.java | 0 .../org/scijava/ops/engine/InfoTreeTest.java | 0 .../ops/engine/OpBuilderNoOutputTest.java | 0 .../org/scijava/ops/engine/OpBuilderTest.java | 0 .../scijava/ops/engine/OpBuilderTestOps.java | 0 .../scijava/ops/engine/OpEnvironmentTest.java | 0 .../org/scijava/ops/engine/OpInfoTest.java | 0 .../scijava/ops/engine/OpMethodBoxTest.java | 0 .../ops/engine/OpMethodInParentTest.java | 0 .../org/scijava/ops/engine/OpMethodTest.java | 0 .../scijava/ops/engine/OpMethodTestOps.java | 0 .../scijava/ops/engine/OpPriorityTest.java | 0 .../java/org/scijava/ops/engine/TestOps.java | 0 .../org/scijava/ops/engine/TestUtils.java | 0 .../AdaptationTypeVariableCaptureTest.java | 0 .../adapt/OpAdaptationPriorityTest.java | 0 .../ComputerToFunctionIterablesTest.java | 0 .../FunctionToComputerIterablesTest.java | 0 .../ComputerToFunctionAdaptTest.java | 0 .../ComputerToFunctionAdaptTestOps.java | 0 .../ComputerToFunctionObjectOutputTest.java | 0 .../FunctionToComputerAdaptTest.java | 0 .../FunctionToComputerAdaptTestOps.java | 0 .../InplaceToFunctionAdaptTest.java | 0 .../PermutedComputerToFunctionAdaptTest.java | 0 .../adapt/lift/ComputerToArraysTest.java | 0 .../adapt/lift/ComputerToIterablesTest.java | 0 .../adapt/lift/FunctionToArraysTest.java | 0 .../adapt/lift/FunctionToIterablesTest.java | 0 .../adapt/lift/InplaceToArraysTest.java | 0 .../ops/engine/copy/CopyOpCollectionTest.java | 0 .../engine/describe/BaseDescriptorsTest.java | 0 .../org/scijava/ops/engine/eval/EvalTest.java | 0 .../ops/engine/hints/AdaptationHintTest.java | 0 .../ops/engine/hints/ConversionHintTest.java | 0 .../DefaultOpDescriptionGeneratorTest.java | 0 .../ops/engine/impl/LambdaTypeBakerTest.java | 0 .../ops/engine/impl/OpCachingTest.java | 0 .../impl/OpMethodDependencyPositionTest.java | 0 .../ops/engine/impl/ProvenanceTest.java | 0 ...ServiceLoaderDiscoveryIntegrationTest.java | 0 .../engine/impl/UnnamedOpExceptionTest.java | 0 .../engine/matcher/MatchingWithAnyTest.java | 0 .../engine/matcher/MatchingWithGCSTTest.java | 0 .../matcher/OpMatchingExceptionTest.java | 0 .../ops/engine/matcher/OpWrappersTest.java | 0 .../engine/matcher/RawTypeMatchingTest.java | 0 .../matcher/adapt/OpAdaptationInfoTest.java | 0 .../convert/ConversionAdaptationTest.java | 0 .../matcher/convert/ConversionIOTest.java | 0 .../convert/ConversionPathwayIgnoreTest.java | 0 .../convert/ConversionPriorityTest.java | 0 .../matcher/convert/ConversionTest.java | 0 .../convert/PrimitiveArrayConverterTest.java | 0 .../convert/PrimitiveListConverterTest.java | 0 .../matcher/impl/DefaultOpFieldInfoTest.java | 0 .../matcher/impl/MatchingUtilsTest.java | 0 .../engine/matcher/impl/OpClassInfoTest.java | 0 .../matcher/impl/OpDescriptionTest.java | 0 .../engine/matcher/impl/OpMethodInfoTest.java | 0 .../ops/engine/math/MathOpCollectionTest.java | 0 .../engine/reduce/BiFunctionWithNullable.java | 0 .../NullableArgumentsFromIFaceTest.java | 0 .../engine/reduce/NullableArgumentsTest.java | 0 .../reduce/ReductionWithDependenciesTest.java | 0 .../ops/engine/reduce/TestOpNullableArg.java | 0 .../reduce/TestOpNullableFromIFace.java | 0 .../scijava/ops/engine/stats/MeanTest.java | 0 .../scijava/ops/engine/stats/SizeTest.java | 0 .../ops/engine/util/ComputersTest.java | 0 .../ops/engine/util/FunctionsTest.java | 0 .../scijava/ops/engine/util/InfosTest.java | 0 .../scijava/ops/engine/util/InplacesTest.java | 0 .../ops/engine/util/LambdaizerTest.java | 0 .../org/scijava/ops/engine/util/LiftTest.java | 0 .../ops/engine/util/OpsAsParametersTest.java | 0 .../ops/engine/yaml/impl/YAMLOpTest.java | 0 .../ops/engine/yaml/impl/ops/YAMLClassOp.java | 0 .../ops/engine/yaml/impl/ops/YAMLFieldOp.java | 0 .../engine/yaml/impl/ops/YAMLMethodOp.java | 0 .../templates/main/java/module-info.list | 0 .../templates/main/java/module-info.vm | 0 .../ComputersToFunctionsAndLift.list | 0 .../ComputersToFunctionsAndLift.vm | 0 .../FunctionsToComputersAndLift.list | 0 .../FunctionsToComputersAndLift.vm | 0 .../ComputersToFunctionsViaFunction.list | 0 .../ComputersToFunctionsViaFunction.vm | 0 .../ComputersToFunctionsViaSource.list | 0 .../ComputersToFunctionsViaSource.vm | 0 .../functional/FunctionsToComputers.list | 0 .../adapt/functional/FunctionsToComputers.vm | 0 .../adapt/functional/InplacesToFunctions.list | 0 .../adapt/functional/InplacesToFunctions.vm | 0 .../engine/adapt/lift/ComputerToArrays.list | 0 .../ops/engine/adapt/lift/ComputerToArrays.vm | 0 .../adapt/lift/ComputerToIterables.list | 0 .../engine/adapt/lift/ComputerToIterables.vm | 0 .../engine/adapt/lift/FunctionToArrays.list | 0 .../ops/engine/adapt/lift/FunctionToArrays.vm | 0 .../adapt/lift/FunctionToIterables.list | 0 .../engine/adapt/lift/FunctionToIterables.vm | 0 .../engine/adapt/lift/InplaceToArrays.list | 0 .../ops/engine/adapt/lift/InplaceToArrays.vm | 0 .../ops/engine/matcher/impl/OpWrappers.list | 0 .../ops/engine/matcher/impl/OpWrappers.vm | 0 .../ops/engine/util/FunctionUtils.list | 0 .../scijava/ops/engine/util/FunctionUtils.vm | 0 .../org/scijava/ops/engine/util/Globals.list | 0 .../org/scijava/ops/engine/OpBuilderTest.list | 0 .../org/scijava/ops/engine/OpBuilderTest.vm | 0 .../scijava/ops/engine/OpBuilderTestOps.list | 0 .../scijava/ops/engine/OpBuilderTestOps.vm | 0 .../org/scijava/ops/engine/OpMethodTest.list | 0 .../org/scijava/ops/engine/OpMethodTest.vm | 0 .../scijava/ops/engine/OpMethodTestOps.list | 0 .../org/scijava/ops/engine/OpMethodTestOps.vm | 0 .../ComputerToFunctionIterablesTest.list | 0 .../ComputerToFunctionIterablesTest.vm | 0 .../FunctionToComputerIterablesTest.list | 0 .../FunctionToComputerIterablesTest.vm | 0 .../ComputerToFunctionAdaptTest.list | 0 .../functional/ComputerToFunctionAdaptTest.vm | 0 .../ComputerToFunctionAdaptTestOps.list | 0 .../ComputerToFunctionAdaptTestOps.vm | 0 .../FunctionToComputerAdaptTest.list | 0 .../functional/FunctionToComputerAdaptTest.vm | 0 .../FunctionToComputerAdaptTestOps.list | 0 .../FunctionToComputerAdaptTestOps.vm | 0 .../InplaceToFunctionAdaptTest.list | 0 .../functional/InplaceToFunctionAdaptTest.vm | 0 .../adapt/lift/ComputerToArraysTest.list | 0 .../engine/adapt/lift/ComputerToArraysTest.vm | 0 .../adapt/lift/ComputerToIterablesTest.list | 0 .../adapt/lift/ComputerToIterablesTest.vm | 0 .../adapt/lift/FunctionToArraysTest.list | 0 .../engine/adapt/lift/FunctionToArraysTest.vm | 0 .../adapt/lift/FunctionToIterablesTest.list | 0 .../adapt/lift/FunctionToIterablesTest.vm | 0 .../adapt/lift/InplaceToArraysTest.list | 0 .../engine/adapt/lift/InplaceToArraysTest.vm | 0 .../ops/engine/matcher/OpWrappersTest.list | 0 .../ops/engine/matcher/OpWrappersTest.vm | 0 .../.gitignore | 0 .../LICENSE.txt | 0 .../NOTICE.txt | 0 .../README.md | 0 .../pom.xml | 0 .../src/main/java/module-info.java | 0 .../ops/parser/InvalidOpException.java | 0 .../org/scijava/ops/parser/OpParameter.java | 0 .../java/org/scijava/ops/parser/OpParser.java | 0 .../org/scijava/ops/parser/TestOpParser.java | 0 .../src/test/resources/test-ops.yaml | 0 {scijava-ops-flim => ops-flim}/LICENSE.txt | 0 {scijava-ops-flim => ops-flim}/README.md | 0 .../environment.yml | 0 .../images/example A.png | Bin .../images/example tau.png | Bin .../images/example z.png | Bin .../notebooks/Demo.ipynb | 0 .../notebooks/README.md | 0 {scijava-ops-flim => ops-flim}/pom.xml | 0 .../org/scijava/ops/flim/AbstractFitRAI.java | 0 .../scijava/ops/flim/AmplitudeFractions.java | 0 .../java/org/scijava/ops/flim/FitParams.java | 0 .../java/org/scijava/ops/flim/FitResults.java | 0 .../java/org/scijava/ops/flim/FitWorker.java | 0 .../org/scijava/ops/flim/MeanLifetimes.java | 0 .../org/scijava/ops/flim/ParamEstimator.java | 0 .../org/scijava/ops/flim/Pseudocolor.java | 0 .../ops/flim/impl/AbstractFitWorker.java | 0 .../flim/impl/AbstractSingleFitWorker.java | 0 .../org/scijava/ops/flim/impl/BayesFit.java | 0 .../org/scijava/ops/flim/impl/Creators.java | 0 .../org/scijava/ops/flim/impl/GlobalFit.java | 0 .../org/scijava/ops/flim/impl/LMAFit.java | 0 .../org/scijava/ops/flim/impl/PhasorFit.java | 0 .../org/scijava/ops/flim/impl/RLDFit.java | 0 .../flim/types/FitParamsTypeExtractor.java | 0 .../org/scijava/ops/flim/util/RAHelper.java | 0 .../services/org.scijava.types.TypeExtractor | 0 .../scijava/ops/flim/AbstractFlimTest.java | 0 .../ops/flim/AmplitudeFractionsTest.java | 0 .../java/org/scijava/ops/flim/FitTest.java | 0 .../scijava/ops/flim/MeanLifetimesTest.java | 0 .../java/org/scijava/ops/flim/MiscTest.java | 0 .../test_files/cells.sdt | Bin .../test_files/input.sdt | Bin .../test_files/test2.sdt | Bin {scijava-ops-image => ops-image}/LICENSE.txt | 0 {scijava-ops-image => ops-image}/README.md | 0 .../bin/generate.groovy | 0 {scijava-ops-image => ops-image}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/ops/image/About.java | 0 .../ops/image/adapt/LiftComputersToRAI.java | 0 .../ops/image/adapt/LiftFunctionsToRAI.java | 0 .../adapt/LiftNeighborhoodComputersToRAI.java | 0 .../scijava/ops/image/adapt/RAIToIIOps.java | 0 .../ComputersToFunctionsAndLift.java | 0 .../FunctionsToComputersAndLift.java | 0 ...eighborhoodComputersToFunctionsOnImgs.java | 0 .../scijava/ops/image/coloc/ColocUtil.java | 0 .../ops/image/coloc/IntArraySorter.java | 0 .../ops/image/coloc/IntComparator.java | 0 .../scijava/ops/image/coloc/MergeSort.java | 0 .../scijava/ops/image/coloc/ShuffledView.java | 0 .../scijava/ops/image/coloc/icq/LiICQ.java | 0 .../coloc/kendallTau/KendallTauBRank.java | 0 .../ops/image/coloc/maxTKendallTau/MTKT.java | 0 .../ops/image/coloc/pValue/DefaultPValue.java | 0 .../ops/image/coloc/pValue/PValueResult.java | 0 .../image/coloc/pearsons/DefaultPearsons.java | 0 .../saca/AdaptiveSmoothedKendallTau.java | 0 .../image/coloc/saca/SACAHeatmapZScore.java | 0 .../ops/image/coloc/saca/SACASigMask.java | 0 .../ops/image/coloc/saca/WtKendallTau.java | 0 .../image/convert/ConvertComplexTypes.java | 0 .../ops/image/convert/ConvertImages.java | 0 .../convert/ConvertNumbersToRealTypes.java | 0 .../scijava/ops/image/convert/Converters.java | 0 .../org/scijava/ops/image/convert/Types.java | 0 .../ops/image/convert/clip/ClipRealTypes.java | 0 .../image/convert/copy/CopyComplexTypes.java | 0 .../org/scijava/ops/image/copy/Copiers.java | 0 .../scijava/ops/image/create/CreateTypes.java | 0 .../scijava/ops/image/create/Creators.java | 0 .../DefaultCreateKernel2ndDerivBiGauss.java | 0 .../create/DefaultCreateKernelBiGauss.java | 0 .../create/DefaultCreateKernelGabor.java | 0 .../create/DefaultCreateKernelGauss.java | 0 .../DefaultCreateKernelGibsonLanni.java | 0 .../image/create/DefaultCreateKernelLog.java | 0 .../create/DefaultCreateKernelSobel.java | 0 .../org/scijava/ops/image/create/Imgs.java | 0 .../image/deconvolve/AccelerationState.java | 0 .../deconvolve/NonCirculantFirstGuess.java | 0 .../NonCirculantNormalizationFactor.java | 0 .../deconvolve/PadAndRichardsonLucy.java | 0 .../deconvolve/PadAndRichardsonLucyTV.java | 0 .../ops/image/deconvolve/RichardsonLucyC.java | 0 .../deconvolve/RichardsonLucyCorrection.java | 0 .../deconvolve/RichardsonLucyTVUpdate.java | 0 .../deconvolve/RichardsonLucyUpdate.java | 0 .../image/deconvolve/VectorAccelerator.java | 0 .../image/describe/ImgLib2Descriptors.java | 0 .../haralick/AbstractHaralickFeature.java | 0 .../image/features/haralick/DefaultASM.java | 0 .../haralick/DefaultClusterProminence.java | 0 .../haralick/DefaultClusterShade.java | 0 .../features/haralick/DefaultContrast.java | 0 .../features/haralick/DefaultCorrelation.java | 0 .../haralick/DefaultDifferenceEntropy.java | 0 .../haralick/DefaultDifferenceVariance.java | 0 .../features/haralick/DefaultEntropy.java | 0 .../image/features/haralick/DefaultICM1.java | 0 .../image/features/haralick/DefaultICM2.java | 0 .../image/features/haralick/DefaultIFDM.java | 0 .../haralick/DefaultMaxProbability.java | 0 .../features/haralick/DefaultSumAverage.java | 0 .../features/haralick/DefaultSumEntropy.java | 0 .../features/haralick/DefaultSumVariance.java | 0 .../haralick/DefaultTextureHomogeneity.java | 0 .../features/haralick/DefaultVariance.java | 0 .../features/haralick/helper/CoocHXY.java | 0 .../features/haralick/helper/CoocMeanX.java | 0 .../features/haralick/helper/CoocMeanY.java | 0 .../features/haralick/helper/CoocPX.java | 0 .../haralick/helper/CoocPXMinusY.java | 0 .../features/haralick/helper/CoocPXPlusY.java | 0 .../features/haralick/helper/CoocPY.java | 0 .../features/haralick/helper/CoocStdX.java | 0 .../features/haralick/helper/CoocStdY.java | 0 .../hog/HistogramOfOrientedGradients2D.java | 0 .../image/features/lbp2d/DefaultLBP2D.java | 0 .../tamura2d/DefaultCoarsenessFeature.java | 0 .../tamura2d/DefaultContrastFeature.java | 0 .../DefaultDirectionalityFeature.java | 0 .../zernike/DefaultMagnitudeFeature.java | 0 .../features/zernike/DefaultPhaseFeature.java | 0 .../ops/image/features/zernike/Polynom.java | 0 .../features/zernike/ZernikeComputer.java | 0 .../image/features/zernike/ZernikeMoment.java | 0 .../CenterAwareNeighborhoodBasedFilter.java | 0 .../filter/FFTMethodsLinearFFTFilterC.java | 0 .../ops/image/filter/NeighborhoodFilters.java | 0 .../image/filter/addNoise/NoiseAdders.java | 0 .../filter/bilateral/DefaultBilateral.java | 0 .../image/filter/convolve/ConvolveFFTC.java | 0 .../image/filter/convolve/ConvolveFFTF.java | 0 .../filter/convolve/NaiveConvolutions.java | 0 .../image/filter/correlate/CorrelateFFTC.java | 0 .../image/filter/correlate/CorrelateFFTF.java | 0 .../derivative/PartialDerivativeRAI.java | 0 .../derivative/PartialDerivativesRAI.java | 0 .../DefaultDerivativeGauss.java | 0 .../ops/image/filter/dog/DefaultDoG.java | 0 .../image/filter/dog/DoGVaryingSigmas.java | 0 .../scijava/ops/image/filter/fft/FFTs.java | 0 .../filter/fftSize/ComputeFFTMethodsSize.java | 0 .../image/filter/fftSize/ComputeFFTSize.java | 0 .../filter/fftSize/DefaultComputeFFTSize.java | 0 .../image/filter/fftSize/NextPowerOfTwo.java | 0 .../filter/fftSize/NextSmoothNumber.java | 0 .../ops/image/filter/gauss/Gaussians.java | 0 .../ops/image/filter/hessian/HessianRAI.java | 0 .../ops/image/filter/ifft/IFFTMethodsOpC.java | 0 .../ops/image/filter/ifft/IFFTMethodsOpI.java | 0 .../image/filter/pad/DefaultPadInputFFT.java | 0 .../filter/pad/DefaultPadShiftKernelFFT.java | 0 .../ops/image/filter/pad/PadInput.java | 0 .../ops/image/filter/pad/PadInputFFT.java | 0 .../image/filter/pad/PadInputFFTMethods.java | 0 .../ops/image/filter/pad/PadShiftKernel.java | 0 .../filter/pad/PadShiftKernelFFTMethods.java | 0 .../filter/pad/PaddingIntervalCentered.java | 0 .../filter/pad/PaddingIntervalOrigin.java | 0 .../filter/sigma/DefaultSigmaFilter.java | 0 .../ops/image/filter/sobel/SobelRAI.java | 0 .../filter/tubeness/DefaultTubeness.java | 0 .../filter/vesselness/DefaultFrangi.java | 0 .../geom/AbstractBoundarySizeConvexHull.java | 0 .../ops/image/geom/AbstractBoxivity.java | 0 .../ops/image/geom/AbstractConvexity.java | 0 .../image/geom/AbstractSizeConvexHull.java | 0 .../ops/image/geom/AbstractSolidity.java | 0 .../scijava/ops/image/geom/CentroidII.java | 0 .../ops/image/geom/CentroidLabelRegion.java | 0 .../ops/image/geom/CentroidPolygon.java | 0 .../image/geom/DefaultCenterOfGravity.java | 0 .../org/scijava/ops/image/geom/GeomUtils.java | 0 .../org/scijava/ops/image/geom/SizeII.java | 0 .../DefaultBoundarySizeConvexHullPolygon.java | 0 .../image/geom/geom2d/DefaultBoundingBox.java | 0 .../geom/geom2d/DefaultBoxivityPolygon.java | 0 .../image/geom/geom2d/DefaultCircularity.java | 0 .../ops/image/geom/geom2d/DefaultContour.java | 0 .../geom/geom2d/DefaultConvexHull2D.java | 0 .../geom/geom2d/DefaultConvexityPolygon.java | 0 .../geom/geom2d/DefaultEccentricity.java | 0 .../image/geom/geom2d/DefaultElongation.java | 0 .../image/geom/geom2d/DefaultFeretsAngle.java | 0 .../geom/geom2d/DefaultFeretsDiameter.java | 0 .../geom2d/DefaultFeretsDiameterForAngle.java | 0 .../image/geom/geom2d/DefaultMajorAxis.java | 0 .../geom/geom2d/DefaultMaximumFeret.java | 0 .../geom/geom2d/DefaultMaximumFeretAngle.java | 0 .../geom2d/DefaultMaximumFeretDiameter.java | 0 .../geom/geom2d/DefaultMinimumFeret.java | 0 .../geom/geom2d/DefaultMinimumFeretAngle.java | 0 .../geom2d/DefaultMinimumFeretDiameter.java | 0 .../image/geom/geom2d/DefaultMinorAxis.java | 0 .../geom/geom2d/DefaultMinorMajorAxis.java | 0 .../geom/geom2d/DefaultPerimeterLength.java | 0 .../image/geom/geom2d/DefaultRoundness.java | 0 .../geom2d/DefaultSizeConvexHullPolygon.java | 0 .../image/geom/geom2d/DefaultSizePolygon.java | 0 .../DefaultSmallestEnclosingRectangle.java | 0 .../geom/geom2d/DefaultSolidityPolygon.java | 0 ...DefaultVerticesCountConvexHullPolygon.java | 0 .../geom2d/DefaultVerticesCountPolygon.java | 0 .../geom/geom3d/DefaultInertiaTensor3D.java | 0 .../scijava/ops/image/geom/geom3d/Facet.java | 0 .../ops/image/geom/geom3d/Horizon.java | 0 .../image/geom/geom3d/TriangularFacet.java | 0 .../image/geom/geom3d/UpdateablePointSet.java | 0 .../scijava/ops/image/geom/geom3d/Vertex.java | 0 .../image/geom/geom3d/VertexInterpolator.java | 0 .../ops/image/image/ascii/DefaultASCII.java | 0 .../CooccurrenceMatrix.java | 0 .../CooccurrenceMatrix2D.java | 0 .../CooccurrenceMatrix3D.java | 0 .../cooccurrenceMatrix/MatrixOrientation.java | 0 .../MatrixOrientation2D.java | 0 .../MatrixOrientation3D.java | 0 .../CalibratedDistanceTransformer.java | 0 .../DefaultDistanceTransform.java | 0 .../DefaultDistanceTransformCalibration.java | 0 .../DistanceTransform2D.java | 0 .../DistanceTransform2DCalibration.java | 0 .../DistanceTransform3D.java | 0 .../DistanceTransform3DCalibration.java | 0 .../DistanceTransformer.java | 0 .../scijava/ops/image/image/fill/Fills.java | 0 .../image/histogram/HistogramCreate.java | 0 .../image/integral/AbstractIntegralImg.java | 0 .../image/integral/DefaultIntegralImg.java | 0 .../image/image/integral/IntegralCursor.java | 0 .../image/integral/SquareIntegralImg.java | 0 .../image/integral/WrappedIntegralImg.java | 0 .../ops/image/image/invert/InvertII.java | 0 .../image/image/invert/InvertIIInteger.java | 0 .../ops/image/image/invert/Inverters.java | 0 .../image/normalize/NormalizeIIComputer.java | 0 .../image/normalize/NormalizeIIFunction.java | 0 .../image/normalize/NormalizeIILazy.java | 0 .../normalize/NormalizeIILazyFunction.java | 0 .../normalize/NormalizeRealTypeComputer.java | 0 .../ops/image/image/watershed/Watershed.java | 0 .../image/watershed/WatershedBinary.java | 0 .../watershed/WatershedBinaryFunction.java | 0 .../watershed/WatershedBinarySingleSigma.java | 0 .../WatershedBinarySingleSigmaFunction.java | 0 .../image/watershed/WatershedFunction.java | 0 .../image/watershed/WatershedSeeded.java | 0 .../watershed/WatershedSeededFunction.java | 0 .../imagemoments/AbstractImageMomentOp.java | 0 .../DefaultCentralMoment00.java | 0 .../DefaultCentralMoment01.java | 0 .../DefaultCentralMoment02.java | 0 .../DefaultCentralMoment03.java | 0 .../DefaultCentralMoment10.java | 0 .../DefaultCentralMoment11.java | 0 .../DefaultCentralMoment12.java | 0 .../DefaultCentralMoment20.java | 0 .../DefaultCentralMoment21.java | 0 .../DefaultCentralMoment30.java | 0 .../IterableCentralMoment00.java | 0 .../IterableCentralMoment11.java | 0 .../imagemoments/hu/DefaultHuMoment1.java | 0 .../imagemoments/hu/DefaultHuMoment2.java | 0 .../imagemoments/hu/DefaultHuMoment3.java | 0 .../imagemoments/hu/DefaultHuMoment4.java | 0 .../imagemoments/hu/DefaultHuMoment5.java | 0 .../imagemoments/hu/DefaultHuMoment6.java | 0 .../imagemoments/hu/DefaultHuMoment7.java | 0 .../imagemoments/moments/DefaultMoment00.java | 0 .../imagemoments/moments/DefaultMoment01.java | 0 .../imagemoments/moments/DefaultMoment10.java | 0 .../imagemoments/moments/DefaultMoment11.java | 0 .../DefaultNormalizedCentralMoment02.java | 0 .../DefaultNormalizedCentralMoment03.java | 0 .../DefaultNormalizedCentralMoment11.java | 0 .../DefaultNormalizedCentralMoment12.java | 0 .../DefaultNormalizedCentralMoment20.java | 0 .../DefaultNormalizedCentralMoment21.java | 0 .../DefaultNormalizedCentralMoment30.java | 0 .../ops/image/labeling/MergeLabeling.java | 0 .../ops/image/labeling/cca/DefaultCCA.java | 0 .../ops/image/linalg/rotate/Rotations.java | 0 .../ops/image/logic/BooleanTypeLogic.java | 0 .../org/scijava/ops/image/logic/Default.java | 0 .../org/scijava/ops/image/logic/Ternary.java | 0 .../neighborhood/DefaultMapNeighborhood.java | 0 .../MapNeighborhoodWithCenter.java | 0 .../ops/image/math/BinaryNumericTypeMath.java | 0 .../ops/image/math/BinaryRealTypeMath.java | 0 .../image/math/NullaryNumericTypeMath.java | 0 .../ops/image/math/UnaryRealTypeMath.java | 0 .../multiply/ComplexConjugateMultiplyOp.java | 0 .../ops/image/morphology/BlackTopHats.java | 0 .../scijava/ops/image/morphology/Closes.java | 0 .../ops/image/morphology/Dilations.java | 0 .../image/morphology/DistanceTransforms.java | 0 .../ops/image/morphology/Erosions.java | 0 .../ops/image/morphology/ExtractHoles.java | 0 .../ops/image/morphology/FillHoles.java | 0 .../ops/image/morphology/FloodFills.java | 0 .../scijava/ops/image/morphology/Opens.java | 0 .../scijava/ops/image/morphology/Outline.java | 0 .../ops/image/morphology/Table2Ds.java | 0 .../ops/image/morphology/WhiteTopHats.java | 0 .../Abstract3x3NeighbourhoodThinning.java | 0 .../image/morphology/thin/AbstractThin.java | 0 .../thin/GuoHallThinningStrategy.java | 0 .../thin/HilditchThinningStrategy.java | 0 .../thin/MorphologicalThinningStrategy.java | 0 .../image/morphology/thin/ThinGuoHall.java | 0 .../image/morphology/thin/ThinHilditch.java | 0 .../morphology/thin/ThinMorphological.java | 0 .../image/morphology/thin/ThinZhangSuen.java | 0 .../morphology/thin/ThinningStrategy.java | 0 .../thin/ZhangSuenThinningStrategy.java | 0 .../DefaultDetectJunctions.java | 0 .../detectRidges/DefaultDetectRidges.java | 0 .../detectRidges/RidgeDetectionMetadata.java | 0 .../detectRidges/RidgeDetectionUtils.java | 0 .../ops/image/stats/DefaultGeometricMean.java | 0 .../ops/image/stats/DefaultHarmonicMean.java | 0 .../ops/image/stats/DefaultKurtosis.java | 0 .../scijava/ops/image/stats/DefaultMax.java | 0 .../scijava/ops/image/stats/DefaultMean.java | 0 .../ops/image/stats/DefaultMedian.java | 0 .../scijava/ops/image/stats/DefaultMin.java | 0 .../ops/image/stats/DefaultMinMax.java | 0 .../image/stats/DefaultMoment1AboutMean.java | 0 .../image/stats/DefaultMoment2AboutMean.java | 0 .../image/stats/DefaultMoment3AboutMean.java | 0 .../image/stats/DefaultMoment4AboutMean.java | 0 .../image/stats/DefaultMomentNAboutMean.java | 0 .../scijava/ops/image/stats/DefaultPNorm.java | 0 .../ops/image/stats/DefaultPercentile.java | 0 .../scijava/ops/image/stats/DefaultQNorm.java | 0 .../ops/image/stats/DefaultQuantile.java | 0 .../scijava/ops/image/stats/DefaultSize.java | 0 .../ops/image/stats/DefaultSkewness.java | 0 .../image/stats/DefaultStandardDeviation.java | 0 .../scijava/ops/image/stats/DefaultSum.java | 0 .../ops/image/stats/DefaultSumOfInverses.java | 0 .../ops/image/stats/DefaultSumOfLogs.java | 0 .../ops/image/stats/DefaultSumOfSquares.java | 0 .../ops/image/stats/DefaultVariance.java | 0 .../scijava/ops/image/stats/IntegralMean.java | 0 .../scijava/ops/image/stats/IntegralSum.java | 0 .../ops/image/stats/IntegralVariance.java | 0 .../image/stats/IterableGeometricMean.java | 0 .../ops/image/stats/IterableHarmonicMean.java | 0 .../scijava/ops/image/stats/IterableMax.java | 0 .../scijava/ops/image/stats/IterableMean.java | 0 .../scijava/ops/image/stats/IterableMin.java | 0 .../ops/image/stats/IterableMinMax.java | 0 .../image/stats/IterableMoment3AboutMean.java | 0 .../image/stats/IterableMoment4AboutMean.java | 0 .../scijava/ops/image/stats/IterableSize.java | 0 .../stats/IterableStandardDeviation.java | 0 .../scijava/ops/image/stats/IterableSum.java | 0 .../image/stats/IterableSumOfInverses.java | 0 .../ops/image/stats/IterableSumOfLogs.java | 0 .../ops/image/stats/IterableSumOfSquares.java | 0 .../ops/image/stats/IterableVariance.java | 0 .../regression/leastSquares/Quadric.java | 0 .../ops/image/thread/chunker/Chunk.java | 0 .../thread/chunker/ChunkerInterleaved.java | 0 .../thread/chunker/CursorBasedChunk.java | 0 .../image/thread/chunker/DefaultChunker.java | 0 .../threshold/AbstractApplyThresholdImg.java | 0 .../AbstractComputeThresholdHistogram.java | 0 .../ApplyLocalThresholdIntegral.java | 0 .../image/threshold/ApplyThresholdMethod.java | 0 .../threshold/ApplyThresholdMethodLocal.java | 0 .../ops/image/threshold/Thresholds.java | 0 .../apply/ApplyConstantThreshold.java | 0 .../apply/ApplyThresholdComparable.java | 0 .../apply/ApplyThresholdComparator.java | 0 .../huang/ComputeHuangThreshold.java | 0 .../threshold/ij1/ComputeIJ1Threshold.java | 0 .../ComputeIntermodesThreshold.java | 0 .../isoData/ComputeIsoDataThreshold.java | 0 .../threshold/li/ComputeLiThreshold.java | 0 .../ComputeLocalBernsenThreshold.java | 0 .../localBernsen/LocalBernsenThreshold.java | 0 .../ComputeLocalContrastThreshold.java | 0 .../localContrast/LocalContrastThreshold.java | 0 .../localMean/ComputeLocalMeanThreshold.java | 0 .../ComputeLocalMeanThresholdIntegral.java | 0 .../localMean/LocalMeanThreshold.java | 0 .../ComputeLocalMedianThreshold.java | 0 .../localMedian/LocalMedianThreshold.java | 0 .../ComputeLocalMidGreyThreshold.java | 0 .../localMidGrey/LocalMidGreyThreshold.java | 0 .../ComputeLocalNiblackThreshold.java | 0 .../ComputeLocalNiblackThresholdIntegral.java | 0 .../localNiblack/LocalNiblackThreshold.java | 0 .../ComputeLocalPhansalkarThreshold.java | 0 ...mputeLocalPhansalkarThresholdIntegral.java | 0 .../LocalPhansalkarThreshold.java | 0 .../ComputeLocalSauvolaThreshold.java | 0 .../ComputeLocalSauvolaThresholdIntegral.java | 0 .../localSauvola/LocalSauvolaThreshold.java | 0 .../ComputeMaxEntropyThreshold.java | 0 .../ComputeMaxLikelihoodThreshold.java | 0 .../threshold/mean/ComputeMeanThreshold.java | 0 .../minError/ComputeMinErrorThreshold.java | 0 .../minimum/ComputeMinimumThreshold.java | 0 .../moments/ComputeMomentsThreshold.java | 0 .../threshold/otsu/ComputeOtsuThreshold.java | 0 .../ComputePercentileThreshold.java | 0 .../ComputeRenyiEntropyThreshold.java | 0 .../rosin/ComputeRosinThreshold.java | 0 .../shanbhag/ComputeShanbhagThreshold.java | 0 .../triangle/ComputeTriangleThreshold.java | 0 .../threshold/yen/ComputeYenThreshold.java | 0 .../scijava/ops/image/topology/BoxCount.java | 0 .../EulerCharacteristic26N.java | 0 .../EulerCharacteristic26NFloating.java | 0 .../eulerCharacteristic/EulerCorrection.java | 0 .../topology/eulerCharacteristic/Octant.java | 0 .../image/transform/DefaultTransformView.java | 0 .../ops/image/transform/Transforms.java | 0 .../project/ProjectParallelComputer.java | 0 .../project/ProjectParallelFunction.java | 0 .../transform/slice/slice/SliceRAI2RAI.java | 0 .../image/transform/slice/slice/SlicesII.java | 0 .../image/types/ExtendedRAITypeExtractor.java | 0 .../image/types/Histogram1dTypeExtractor.java | 0 .../image/types/ImgFactoryTypeExtractor.java | 0 .../image/types/ImgLabelingTypeExtractor.java | 0 .../types/LabelingMappingTypeExtractor.java | 0 .../image/types/NativeImgTypeExtractor.java | 0 ...undsConstantValueFactoryTypeExtractor.java | 0 .../OutOfBoundsFactoryTypeExtractor.java | 0 ...BoundsRandomValueFactoryTypeExtractor.java | 0 .../ops/image/types/RAITypeExtractor.java | 0 .../types/maxValue/MaxValueRealTypes.java | 0 .../types/minValue/MinValueRealTypes.java | 0 .../scijava/ops/image/util/BigComplex.java | 0 .../ops/image/util/UnboundedIntegerType.java | 0 .../org.scijava.types.extract.TypeExtractor | 0 .../ops/image/AbstractColocalisationTest.java | 0 .../ops/image/AbstractFeatureTest.java | 0 .../org/scijava/ops/image/AbstractOpTest.java | 0 .../scijava/ops/image/OpRegressionTest.java | 0 .../image/adapt/LiftFunctionsToImgTest.java | 0 .../ops/image/coloc/MergeSortTest.java | 0 .../ops/image/coloc/ShuffledViewTest.java | 0 .../ops/image/coloc/icq/LiICQTest.java | 0 .../coloc/kendallTau/KendallTauBRankTest.java | 0 .../image/coloc/maxTKendallTau/MTKTTest.java | 0 .../image/coloc/pValue/DefaultPValueTest.java | 0 .../coloc/pearsons/DefaultPearsonsTest.java | 0 .../ops/image/coloc/saca/SACATest.java | 0 .../ops/image/convert/ConvertIIsTest.java | 0 .../ops/image/convert/ConvertMapTest.java | 0 .../ops/image/convert/RAIConvertersTest.java | 0 .../convert/TestConvertComplexTypes.java | 0 .../ops/image/convert/TestConvertImages.java | 0 .../TestConvertNumbersToRealTypes.java | 0 .../scijava/ops/image/copy/CopyIITest.java | 0 .../ops/image/copy/CopyImgLabelingTest.java | 0 .../scijava/ops/image/copy/CopyImgTest.java | 0 .../scijava/ops/image/copy/CopyImgsTest.java | 0 .../image/copy/CopyLabelingMappingTest.java | 0 .../scijava/ops/image/copy/CopyRAITest.java | 0 .../scijava/ops/image/copy/CopyTypeTest.java | 0 .../ops/image/create/CreateImgTest.java | 0 .../CreateKernel2ndDerivBiGaussTest.java | 0 .../image/create/CreateKernelBiGaussTest.java | 0 .../create/CreateKernelDiffractionTest.java | 0 .../image/create/CreateKernelGaborTest.java | 0 .../image/create/CreateKernelGaussTest.java | 0 .../ops/image/create/CreateKernelLogTest.java | 0 .../ops/image/create/CreateLabelingTest.java | 0 .../image/create/CreateNativeTypeTest.java | 0 .../ops/image/deconvolve/DeconvolveTest.java | 0 .../describe/ImgLib2DescriptorsTest.java | 0 .../haralick/HaralickFeatureTest.java | 0 .../HistogramOfOrientedGradients2DTest.java | 0 .../features/lbp2d/LBP2dFeatureTest.java | 0 .../tamura2d/Tamura2dFeatureTest.java | 0 .../features/zernike/ZernikeFeatureTest.java | 0 .../org/scijava/ops/image/filter/FFTTest.java | 0 .../image/filter/NonLinearFiltersTest.java | 0 .../filter/addNoise/NoiseAddersTest.java | 0 .../bilateral/DefaultBilateralTest.java | 0 .../image/filter/convolve/ConvolveTest.java | 0 .../image/filter/correlate/CorrelateTest.java | 0 .../PartialDerivativeFilterTest.java | 0 .../DefaultDerivativeGaussTest.java | 0 .../scijava/ops/image/filter/dog/DoGTest.java | 0 .../filter/fftSize/NextSmoothNumberTest.java | 0 .../ops/image/filter/gauss/GaussTest.java | 0 .../filter/hessian/HessianFilterTest.java | 0 .../ops/image/filter/mean/MeanFilterTest.java | 0 .../image/filter/sobel/SobelFilterTest.java | 0 .../image/filter/tubeness/TubenessTest.java | 0 .../vesselness/FrangiVesselnessTest.java | 0 .../geom/geom2d/PolygonFeatureTests.java | 0 .../image/geom/geom3d/MeshFeatureTests.java | 0 .../image/geom/geom3d/QuickHull3DTest.java | 0 .../ops/image/image/ascii/ASCIITest.java | 0 .../DefaultDistanceTransformTest.java | 0 .../DistanceTransform2DTest.java | 0 .../DistanceTransform3DTest.java | 0 .../ops/image/image/fill/FillTest.java | 0 .../image/histogram/DefaultHistogramTest.java | 0 .../image/integral/IntegralCursorTest.java | 0 .../image/image/integral/IntegralImgTest.java | 0 .../image/integral/SquareIntegralImgTest.java | 0 .../ops/image/image/invert/InvertTest.java | 0 .../image/image/normalize/NormalizeTest.java | 0 .../WatershedBinarySingleSigmaTest.java | 0 .../image/watershed/WatershedBinaryTest.java | 0 .../image/watershed/WatershedSeededTest.java | 0 .../image/image/watershed/WatershedTest.java | 0 .../image/imagemoments/ImageMomentsTest.java | 0 .../ops/image/labeling/MergeLabelingTest.java | 0 .../ops/image/linalg/rotate/Rotate3dTest.java | 0 .../ops/image/linalg/rotate/Rotate3fTest.java | 0 .../ops/image/logic/BooleanTypeLogicTest.java | 0 .../ops/image/logic/ConditionalTest.java | 0 .../map/neighborhood/MapNeighborhoodTest.java | 0 .../image/math/BinaryNumericTypeMathTest.java | 0 .../image/math/BinaryRealTypeMathTest.java | 0 .../ops/image/math/ImageBinaryMathTest.java | 0 .../ops/image/math/UnaryRealTypeMathTest.java | 0 .../add/AddNumericTypeBinaryMathAddTest.java | 0 .../image/morphology/MorphologyOpsTest.java | 0 .../blackTopHat/BlackTopHatTest.java | 0 .../image/morphology/outline/OutlineTest.java | 0 .../image/morphology/thin/ThinningTest.java | 0 .../DefaultDetectJunctionsTest.java | 0 .../detectRidges/DefaultDetectRidgesTest.java | 0 .../ops/image/stats/DefaultMedianTest.java | 0 .../ops/image/stats/DefaultPNormTest.java | 0 .../ops/image/stats/DefaultQNormTest.java | 0 .../ops/image/stats/StatisticsTest.java | 0 .../regression/leastSquares/QuadricTest.java | 0 .../threshold/AbstractThresholdTest.java | 0 .../image/threshold/ComputeThresholdTest.java | 0 .../apply/ApplyConstantThresholdTest.java | 0 .../threshold/apply/LocalThresholdTest.java | 0 .../ops/image/topology/BoxCountTest.java | 0 .../EulerCharacteristic26NFloatingTest.java | 0 .../EulerCharacteristic26NTest.java | 0 .../EulerCorrectionTest.java | 0 .../eulerCharacteristic/OctantTest.java | 0 .../eulerCharacteristic/TestHelper.java | 0 .../AddDimensionViewTest.java | 0 .../CollapseNumericViewTest.java | 0 .../CollapseRealViewTest.java | 0 .../collapseView/CollapseViewTest.java | 0 .../concatenateView/ConcatenateViewTest.java | 0 .../DropSingletonDimensionsViewTest.java | 0 .../ExtendBorderViewTest.java | 0 .../ExtendMirrorDoubleViewTest.java | 0 .../ExtendMirrorSingleViewTest.java | 0 .../ExtendPeriodicViewTest.java | 0 .../ExtendRandomViewTest.java | 0 .../extendValueView/ExtendValueViewTest.java | 0 .../transform/extendView/ExtendViewTest.java | 0 .../extendZeroView/ExtendZeroViewTest.java | 0 .../FlatIterableViewTest.java | 0 .../hyperSliceView/HyperSliceViewTest.java | 0 .../interpolateView/InterpolateViewTest.java | 0 .../intervalView/IntervalViewTest.java | 0 .../invertAxisView/InvertAxisViewTest.java | 0 .../transform/offsetView/OffsetViewTest.java | 0 .../permuteView/PermuteViewTest.java | 0 .../image/transform/project/ProjectTest.java | 0 .../transform/rasterView/RasterViewTest.java | 0 .../realTransform/RealTransformTest.java | 0 .../transform/rotateView/RotateViewTest.java | 0 .../transform/shearView/ShearViewTest.java | 0 .../transform/slice/slice/SliceTest.java | 0 .../transform/stackView/StackViewTest.java | 0 .../subsampleView/SubsampleViewTest.java | 0 .../translateView/TranslateViewTest.java | 0 .../unshearView/UnshearViewTest.java | 0 .../zeroMinView/ZeroMinViewTest.java | 0 .../ops/image/types/TypeExtractorTests.java | 0 .../types/adapt/LiftComputersToRAITest.java | 0 .../types/adapt/LiftFunctionsToRAITest.java | 0 .../LiftNeighborhoodComputersToRAITest.java | 0 .../types/maxValue/MaxValueRealTypesTest.java | 0 .../types/minValue/MinValueRealTypesTest.java | 0 .../ops/image/util/BigComplexTest.java | 0 .../ops/image/util/TestImgGeneration.java | 0 .../ops/image/util/TestImgGenerationTest.java | 0 .../ops/image/coloc/colocsample1b-green.tif | Bin .../ops/image/coloc/colocsample1b-red.tif | Bin .../scijava/ops/image/coloc/greenZstack.tif | Bin .../org/scijava/ops/image/coloc/redZstack.tif | Bin .../2d_geometric_features_polygon.txt | 0 .../2d_geometric_features_testlabel.tif | Bin .../features/3d_geometric_features_mesh.txt | 0 .../3d_geometric_features_testlabel.tif | Bin .../ops/image/features/haralick_test_img.tif | Bin .../ops/image/features/hog/HoG2DInput.png | Bin .../ops/image/features/hog/HoG2DResult.tif | Bin .../ops/image/filter/tubeness/TubesInput.png | Bin .../ops/image/filter/tubeness/tube.tif | Bin .../ops/image/filter/vesselness/Result.tif | Bin .../image/watershed/watershed_test_image.png | Bin .../ops/image/morphology/img_with_holes.png | Bin .../image/morphology/img_without_holes.png | Bin .../inverted_img_with_filled_holes.png | Bin .../image/morphology/thin/result_guoHall.tif | Bin .../image/morphology/thin/result_hilditch.tif | Bin .../morphology/thin/result_morphological.tif | Bin .../morphology/thin/result_zhangSuen.tif | Bin .../transform/realTransform/lowresbridge.tif | Bin .../realTransform/rotatedscaledcenter.tif | Bin .../org/scijava/ops/image/adapt/Globals.list | 0 .../ops/image/adapt/LiftComputersToRAI.list | 0 .../ops/image/adapt/LiftComputersToRAI.vm | 0 .../ops/image/adapt/LiftFunctionsToRAI.list | 0 .../ops/image/adapt/LiftFunctionsToRAI.vm | 0 .../ComputersToFunctionsAndLift.list | 0 .../ComputersToFunctionsAndLift.vm | 0 .../FunctionsToComputersAndLift.list | 0 .../FunctionsToComputersAndLift.vm | 0 .../ops/image/adapt/complexLift/Globals.list | 0 .../ops/image/convert/RAIConvertersTest.list | 0 .../ops/image/convert/RAIConvertersTest.vm | 0 .../convert/TestConvertComplexTypes.list | 0 .../image/convert/TestConvertComplexTypes.vm | 0 .../ops/image/convert/TestConvertImages.list | 0 .../ops/image/convert/TestConvertImages.vm | 0 .../.gitignore | 0 .../LICENSE.txt | 0 .../NOTICE.txt | 0 .../README.md | 0 {scijava-ops-indexer => ops-indexer}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../ops/indexer/InvalidOpImplException.java | 0 .../scijava/ops/indexer/OpClassImplData.java | 0 .../scijava/ops/indexer/OpFieldImplData.java | 0 .../org/scijava/ops/indexer/OpImplData.java | 0 .../scijava/ops/indexer/OpImplNoteParser.java | 0 .../scijava/ops/indexer/OpMethodImplData.java | 0 .../org/scijava/ops/indexer/OpParameter.java | 0 .../scijava/ops/indexer/ProcessingUtils.java | 0 .../javax.annotation.processing.Processor | 0 .../LICENSE.txt | 0 {scijava-ops-opencv => ops-opencv}/README.md | 0 .../opencv-ops.yaml | 0 {scijava-ops-opencv => ops-opencv}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/ops/opencv/MatCopier.java | 0 .../scijava/ops/opencv/OpenCVDescriptors.java | 0 .../src/main/resources/ops.yaml | 0 .../org/scijava/ops/opencv/TestOpenCV.java | 0 .../src/test/resources/HappyFish.jpg | Bin {scijava-ops-spi => ops-spi}/.gitignore | 0 {scijava-ops-spi => ops-spi}/LICENSE.txt | 0 {scijava-ops-spi => ops-spi}/README.md | 0 {scijava-ops-spi => ops-spi}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/ops/spi/Nullable.java | 0 .../src/main/java/org/scijava/ops/spi/Op.java | 0 .../java/org/scijava/ops/spi/OpClass.java | 0 .../org/scijava/ops/spi/OpCollection.java | 0 .../org/scijava/ops/spi/OpDependency.java | 0 .../scijava/ops/spi/OpExecutionException.java | 0 .../java/org/scijava/ops/spi/OpField.java | 0 .../java/org/scijava/ops/spi/OpHints.java | 0 .../java/org/scijava/ops/spi/OpMethod.java | 0 .../LICENSE.txt | 0 .../README.md | 0 .../environment.yml | 0 .../notebooks/SciJavaOps.ipynb | 0 .../notebooks/gateways.py | 0 .../pom.xml | 0 .../resources/ops-dot-math.png | Bin .../resources/ops-dot.png | Bin .../scripts/gateways.py | 0 .../scripts/ops-setup.py | 0 .../scripts/services-test.py | 0 .../src/main/java/module-info.java | 0 .../scijava/ops/tutorial/OpAdaptation.java | 0 .../org/scijava/ops/tutorial/OpBuilder.java | 0 .../scijava/ops/tutorial/OpConversion.java | 0 .../scijava/ops/tutorial/OpDependencies.java | 0 .../ops/tutorial/OpParallelization.java | 0 .../scijava/ops/tutorial/OpPriorities.java | 0 .../org/scijava/ops/tutorial/OpReduction.java | 0 .../org/scijava/ops/tutorial/OpTypes.java | 0 .../org/scijava/ops/tutorial/OpsIntro.java | 0 .../ops/tutorial/ParallelComputation.java | 0 .../ops/tutorial/ReportingProgress.java | 0 .../org/scijava/ops/tutorial/UsingNils.java | 0 .../ops/tutorial/WritingConciseOps.java | 0 pom.xml | 89 +++++++++--------- {scijava-priority => priority}/.gitignore | 0 {scijava-priority => priority}/LICENSE.txt | 0 {scijava-priority => priority}/README.md | 0 {scijava-priority => priority}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/priority/Prioritized.java | 0 .../java/org/scijava/priority/Priority.java | 0 .../org/scijava/priority/PrioritizedTest.java | 0 {scijava-progress => progress}/.gitignore | 0 {scijava-progress => progress}/LICENSE.txt | 0 {scijava-progress => progress}/README.md | 0 {scijava-progress => progress}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/progress/Progress.java | 0 .../main/java/org/scijava/progress/Task.java | 0 .../scijava/progress/CancellationTest.java | 0 .../scijava/progress/DefaultProgressTest.java | 0 .../progress/ImproperReportingTest.java | 0 .../progress/ParallelProgressTest.java | 0 .../progress/StatusBasedProgressTest.java | 0 .../scijava/progress/SubtaskProgressTest.java | 0 .../progress/TestSuiteTaskConsumer.java | 0 {scijava-script3 => script3}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/script3/JsrLanguage.java | 0 .../org/scijava/script3/ScriptException.java | 0 .../org/scijava/script3/ScriptExecutable.java | 0 .../org/scijava/script3/ScriptFinder.java | 0 .../org/scijava/script3/ScriptHeader.java | 0 .../org/scijava/script3/ScriptLanguage.java | 0 .../org/scijava/script3/ScriptSession.java | 0 .../java/org/scijava/script3/Scripts.java | 0 .../org/scijava/script3/GroovyScriptTest.java | 0 .../org/scijava/script3/ScriptFinderTest.java | 0 .../org/scijava/script3/ScriptHeaderTest.java | 0 .../scripts/Process/Filters/Blur_It.groovy | 0 .../test/resources/scripts/Say_Hello.groovy | 0 {scijava-settings => settings}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/settings/LoadInputs.java | 0 .../scijava/settings/RememberedInputs.java | 0 .../java/org/scijava/settings/SaveInputs.java | 0 .../java/org/scijava/settings/Settings.java | 0 .../main/java/org/scijava/settings/Toml.java | 0 .../org/scijava/settings/TomlException.java | 0 .../settings/RememberedInputsTest.java | 0 .../org/scijava/settings/SettingsTest.java | 0 .../java/org/scijava/settings/TomlTest.java | 0 {scijava-spi => spi}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/spi/Disposable.java | 0 .../java/org/scijava/spi/GenericTyped.java | 0 .../java/org/scijava/spi/Identifiable.java | 0 .../src/main/java/org/scijava/spi/Named.java | 0 .../main/java/org/scijava/spi/Versioned.java | 0 {scijava-struct => struct}/LICENSE.txt | 0 {scijava-struct => struct}/README.md | 0 {scijava-struct => struct}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../main/java/org/scijava/struct/ItemIO.java | 0 .../main/java/org/scijava/struct/Member.java | 0 .../org/scijava/struct/MemberInstance.java | 0 .../java/org/scijava/struct/MemberParser.java | 0 .../main/java/org/scijava/struct/Struct.java | 0 .../org/scijava/struct/StructInstance.java | 0 .../main/java/org/scijava/struct/Structs.java | 0 .../org/scijava/struct/ValueAccessible.java | 0 .../struct/ValueAccessibleMemberInstance.java | 0 .../struct/AnnotatedParameterMember.java | 0 .../test/java/org/scijava/struct/Attr.java | 0 .../struct/DefaultMutableParameterMember.java | 0 .../scijava/struct/FieldParameterMember.java | 0 .../struct/FunctionalParameterMember.java | 0 .../org/scijava/struct/ItemVisibility.java | 0 .../struct/MutableParameterMember.java | 0 .../java/org/scijava/struct/Parameter.java | 0 .../org/scijava/struct/ParameterMember.java | 0 .../org/scijava/struct/ParameterStructs.java | 0 .../java/org/scijava/struct/Parameters.java | 0 .../java/org/scijava/struct/StructTest.java | 0 .../org/scijava/struct/ValidityException.java | 0 .../org/scijava/struct/ValidityProblem.java | 0 {scijava-swing => swing}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/swing/SwingChoiceWidget.java | 0 .../swing/SwingChoiceWidgetFactory.java | 0 .../java/org/scijava/swing/SwingDialog.java | 0 .../org/scijava/swing/SwingFileWidget.java | 0 .../scijava/swing/SwingFileWidgetFactory.java | 0 .../scijava/swing/SwingInputHarvester.java | 0 .../java/org/scijava/swing/SwingMenus.java | 0 .../org/scijava/swing/SwingMessageWidget.java | 0 .../swing/SwingMessageWidgetFactory.java | 0 .../org/scijava/swing/SwingNumberWidget.java | 0 .../swing/SwingNumberWidgetFactory.java | 0 .../java/org/scijava/swing/SwingPanel.java | 0 .../org/scijava/swing/SwingPanelFactory.java | 0 .../org/scijava/swing/SwingTextWidget.java | 0 .../scijava/swing/SwingTextWidgetFactory.java | 0 .../org/scijava/swing/SwingToggleWidget.java | 0 .../swing/SwingToggleWidgetFactory.java | 0 .../java/org/scijava/swing/SwingWidget.java | 0 .../org/scijava/swing/SwingWidgetFactory.java | 0 .../test/java/org/scijava/swing/Demos.java | 0 .../org/scijava/swing/ScriptMenuTest.java | 0 .../org/scijava/swing/ScriptWidgetsTest.java | 0 .../test/java/org/scijava/swing/Shell.java | 0 .../org/scijava/swing/SwingMenusTest.java | 0 .../org/scijava/swing/SwingWidgetsTest.java | 0 .../scripts/Process/Filters/Blur_It.groovy | 0 .../test/resources/scripts/Say_Hello.groovy | 0 {scijava-taglets => taglets}/LICENSE.txt | 0 {scijava-taglets => taglets}/README.md | 0 {scijava-taglets => taglets}/pom.xml | 0 .../org/scijava/taglets/ContainerTaglet.java | 0 .../java/org/scijava/taglets/InputTaglet.java | 0 .../org/scijava/taglets/MutableTaglet.java | 0 .../scijava/taglets/OpDependencyTaglet.java | 0 .../org/scijava/taglets/OutputTaglet.java | 0 .../java/org/scijava/taglets/ParamTaglet.java | 0 {scijava-testutil => testutil}/LICENSE.txt | 0 {scijava-testutil => testutil}/README.md | 0 {scijava-testutil => testutil}/pom.xml | 0 .../java/org/scijava/testutil/ArrayIO.java | 0 .../org/scijava/testutil/AssertClose.java | 0 .../org/scijava/testutil/ExampleTypes.java | 0 {scijava-types => types}/LICENSE.txt | 0 {scijava-types => types}/NOTICE.txt | 0 {scijava-types => types}/README.md | 0 {scijava-types => types}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../src/main/java/org/scijava/types/Nil.java | 0 .../java/org/scijava/types/Proxyable.java | 0 .../types/extract/DefaultTypeReifier.java | 0 .../types/extract/IterableTypeExtractor.java | 0 .../types/extract/MapTypeExtractor.java | 0 .../types/extract/SubTypeExtractor.java | 0 .../scijava/types/extract/TypeExtractor.java | 0 .../scijava/types/extract/TypeReifier.java | 0 .../scijava/types/infer/AnyTypeMapping.java | 0 .../types/infer/FunctionalInterfaces.java | 0 .../types/infer/GenericAssignability.java | 0 .../types/infer/TypeInferenceException.java | 0 .../org/scijava/types/infer/TypeMapping.java | 0 .../scijava/types/infer/TypeVarAssigns.java | 0 .../types/infer/WildcardTypeMapping.java | 0 .../java/org/scijava/types/package-info.java | 0 .../org.scijava.types.extract.TypeExtractor | 0 .../java/org/scijava/types/ExampleTypes.java | 0 .../test/java/org/scijava/types/NilTest.java | 0 .../types/extract/DefaultTypeReifierTest.java | 0 .../types/infer/InferTypeVariablesTest.java | 0 {scijava-ui3-test => ui3-test}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../org/scijava/ui3/test/MenuConformance.java | 0 .../org/scijava/ui3/test/SampleCommands.java | 0 .../scijava/ui3/test/WidgetConformance.java | 0 {scijava-ui3 => ui3}/pom.xml | 0 .../src/main/java/module-info.java | 0 .../java/org/scijava/ui3/AbstractWidget.java | 0 .../java/org/scijava/ui3/InputHarvester.java | 0 .../src/main/java/org/scijava/ui3/Widget.java | 0 .../java/org/scijava/ui3/WidgetFactory.java | 0 .../java/org/scijava/ui3/WidgetPanel.java | 0 .../org/scijava/ui3/WidgetPanelFactory.java | 0 .../java/org/scijava/ui3/WidgetPanels.java | 0 .../main/java/org/scijava/ui3/Widgets.java | 0 1556 files changed, 44 insertions(+), 45 deletions(-) rename {scijava-awt => awt}/pom.xml (100%) rename {scijava-awt => awt}/src/main/java/module-info.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtChoice.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtDialog.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtFile.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtInputHarvester.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtMenus.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtMessage.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtNumber.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtPanel.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtText.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtToggle.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtWidget.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/AwtWidgetFactory.java (100%) rename {scijava-awt => awt}/src/main/java/org/scijava/awt/Edt.java (100%) rename {scijava-awt => awt}/src/test/java/org/scijava/awt/AwtMenusTest.java (100%) rename {scijava-awt => awt}/src/test/java/org/scijava/awt/AwtShell.java (100%) rename {scijava-awt => awt}/src/test/java/org/scijava/awt/AwtWidgetsTest.java (100%) rename {scijava-awt => awt}/src/test/resources/scripts/Process/Filters/Blur_It.groovy (100%) rename {scijava-awt => awt}/src/test/resources/scripts/Say_Hello.groovy (100%) rename {scijava-cli => cli}/pom.xml (100%) rename {scijava-cli => cli}/src/main/java/module-info.java (100%) rename {scijava-cli => cli}/src/main/java/org/scijava/cli/Arguments.java (100%) rename {scijava-cli => cli}/src/main/java/org/scijava/cli/Cli.java (100%) rename {scijava-cli => cli}/src/main/java/org/scijava/cli/CommandLookup.java (100%) rename {scijava-cli => cli}/src/test/java/org/scijava/cli/CliCommands.java (100%) rename {scijava-cli => cli}/src/test/java/org/scijava/cli/CliTest.java (100%) rename {scijava-collections => collections}/.gitignore (100%) rename {scijava-collections => collections}/LICENSE.txt (100%) rename {scijava-collections => collections}/README.md (100%) rename {scijava-collections => collections}/pom.xml (100%) rename {scijava-collections => collections}/src/main/java/module-info.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/AbstractPrimitiveArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/BoolArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/ByteArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/CharArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/DoubleArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/FloatArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/IntArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/LongArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/ObjectArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/PrimitiveArray.java (100%) rename {scijava-collections => collections}/src/main/java/org/scijava/collections/ShortArray.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/BoolArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/ByteArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/CharArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/DoubleArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/FloatArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/IntArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/LongArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/ObjectArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/PrimitiveArrayTest.java (100%) rename {scijava-collections => collections}/src/test/java/org/scijava/collections/ShortArrayTest.java (100%) rename {scijava-command3 => command3}/pom.xml (100%) rename {scijava-command3 => command3}/src/main/java/module-info.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/Accelerator.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/ClassCommandInfo.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/Command.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/CommandInfo.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/Commands.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/Menu.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/MenuCreator.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/MenuTree.java (100%) rename {scijava-command3 => command3}/src/main/java/org/scijava/command3/Menus.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/CommandsTest.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/CountBeans.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/DeepCommand.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/Headless.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/HiddenCommand.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/MenuTreeTest.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/NeverRun.java (100%) rename {scijava-command3 => command3}/src/test/java/org/scijava/command3/SayHello.java (100%) rename {scijava-common3 => common3}/.gitignore (100%) rename {scijava-common3 => common3}/LICENSE.txt (100%) rename {scijava-common3 => common3}/README.md (100%) rename {scijava-common3 => common3}/pom.xml (100%) rename {scijava-common3 => common3}/src/main/java/module-info.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Annotations.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Any.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Apps.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Classes.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Comparisons.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Digests.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/FilePaths.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/MersenneTwisterFast.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Numbers.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Platforms.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Processes.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Strings.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Threads.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/Types.java (100%) rename {scijava-common3 => common3}/src/main/java/org/scijava/common3/URLs.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/ClassesTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/CommonSuperTypeTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/DigestsTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/FilePathsTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/NumbersTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/ProcessesTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/StringsTest.java (100%) rename {scijava-common3 => common3}/src/test/java/org/scijava/common3/TypesTest.java (100%) rename {scijava-compat => compat}/pom.xml (100%) rename {scijava-compat => compat}/src/main/java/module-info.java (100%) rename {scijava-compat => compat}/src/main/java/org/scijava/compat/Legacy.java (100%) rename {scijava-compat => compat}/src/main/java/org/scijava/compat/LegacyCommandInfo.java (100%) rename {scijava-compat => compat}/src/main/java/org/scijava/compat/LegacyContext.java (100%) rename {scijava-compat => compat}/src/main/java/org/scijava/compat/LegacyMember.java (100%) rename {scijava-compat => compat}/src/test/java/org/scijava/compat/LegacyBridgeTest.java (100%) rename {scijava-compat => compat}/src/test/java/org/scijava/compat/LegacyCommands.java (100%) rename {scijava-compat => compat}/src/test/java/org/scijava/compat/LegacyDialogTest.java (100%) rename {scijava-concurrent => concurrent}/LICENSE.txt (100%) rename {scijava-concurrent => concurrent}/README.md (100%) rename {scijava-concurrent => concurrent}/pom.xml (100%) rename {scijava-concurrent => concurrent}/src/main/java/module-info.java (100%) rename {scijava-concurrent => concurrent}/src/main/java/org/scijava/concurrent/Parallelization.java (100%) rename {scijava-concurrent => concurrent}/src/main/java/org/scijava/concurrent/TaskExecutor.java (100%) rename {scijava-concurrent => concurrent}/src/main/java/org/scijava/concurrent/TaskExecutors.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/CancellationTest.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/DefaultTaskExecutorTest.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/ParallelizationBenchmark.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/ParallelizationDemo.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/ParallelizationTest.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/TaskExecutorsTest.java (100%) rename {scijava-concurrent => concurrent}/src/test/java/org/scijava/concurrent/package-info.java (100%) rename {scijava-context-test => context-test}/pom.xml (100%) rename {scijava-context-test => context-test}/src/main/java/module-info.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/GreeterService.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/RecordingService.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/ShoutRecorded.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/Shouter.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/impl/InjectedShouter.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/impl/LoudShouter.java (100%) rename {scijava-context-test => context-test}/src/main/java/org/scijava/context/test/impl/QuietShouter.java (100%) rename {scijava-context-test => context-test}/src/test/java/org/scijava/context/test/EventHandlerTest.java (100%) rename {scijava-context-test => context-test}/src/test/java/org/scijava/context/test/InjectionTest.java (100%) rename {scijava-context-test => context-test}/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java (100%) rename {scijava-context-test => context-test}/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java (100%) rename {scijava-context => context}/.factorypath (100%) rename {scijava-context => context}/pom.xml (100%) rename {scijava-context => context}/src/main/java/module-info.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/Access.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/Attr.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/Context.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/Dependency.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/EventHandler.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/NoSuchServiceException.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/Plugin.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/Service.java (100%) rename {scijava-context => context}/src/main/java/org/scijava/context/ServiceException.java (100%) rename {scijava-context => context}/src/test/java/org/scijava/context/ContextTest.java (100%) rename {scijava-convert3 => convert3}/pom.xml (100%) rename {scijava-convert3 => convert3}/src/main/java/module-info.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/ArrayConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/CastConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/CollectionConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/ConstructorConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/ConversionException.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/Converter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/Converters.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/NumberConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/PathConverters.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/StringConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/java/org/scijava/convert3/ToStringConverter.java (100%) rename {scijava-convert3 => convert3}/src/main/resources/META-INF/services/org.scijava.convert3.Converter (100%) rename {scijava-convert3 => convert3}/src/test/java/org/scijava/convert3/ConvertersTest.java (100%) rename {scijava-discovery-test => discovery-test}/.gitignore (100%) rename {scijava-discovery-test => discovery-test}/LICENSE.txt (100%) rename {scijava-discovery-test => discovery-test}/README.md (100%) rename {scijava-discovery-test => discovery-test}/pom.xml (100%) rename {scijava-discovery-test => discovery-test}/src/main/java/module-info.java (100%) rename {scijava-discovery-test => discovery-test}/src/main/java/org/scijava/discovery/test/ServiceBasedAdder.java (100%) rename {scijava-discovery-test => discovery-test}/src/main/java/org/scijava/discovery/test/ServiceBasedMultipliers.java (100%) rename {scijava-discovery-test => discovery-test}/src/main/resources/META-INF/services/org.scijava.ops.spi.Op (100%) rename {scijava-discovery-test => discovery-test}/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection (100%) rename {scijava-discovery-test => discovery-test}/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java (100%) rename {scijava-discovery-test => discovery-test}/src/test/java/org/scijava/discovery/test/ServiceLoaderDiscovererTest.java (100%) rename {scijava-discovery => discovery}/.gitignore (100%) rename {scijava-discovery => discovery}/LICENSE.txt (100%) rename {scijava-discovery => discovery}/README.md (100%) rename {scijava-discovery => discovery}/pom.xml (100%) rename {scijava-discovery => discovery}/src/main/java/module-info.java (100%) rename {scijava-discovery => discovery}/src/main/java/org/scijava/discovery/Discoverer.java (100%) rename {scijava-discovery => discovery}/src/main/java/org/scijava/discovery/Discovery.java (100%) rename {scijava-discovery => discovery}/src/main/java/org/scijava/discovery/ManualDiscoverer.java (100%) rename {scijava-events => events}/pom.xml (100%) rename {scijava-events => events}/src/main/java/module-info.java (100%) rename {scijava-events => events}/src/main/java/org/scijava/events/Consumable.java (100%) rename {scijava-events => events}/src/main/java/org/scijava/events/DefaultEventBus.java (100%) rename {scijava-events => events}/src/main/java/org/scijava/events/EventBus.java (100%) rename {scijava-events => events}/src/main/java/org/scijava/events/EventDeliveryException.java (100%) rename {scijava-events => events}/src/main/java/org/scijava/events/Subscription.java (100%) rename {scijava-events => events}/src/test/java/org/scijava/events/EventBusTest.java (100%) rename {scijava-execute => execute}/pom.xml (100%) rename {scijava-execute => execute}/src/main/java/module-info.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Behavior.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/BehaviorException.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Executable.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/ExecutableInstance.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Executables.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Execution.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/ExecutionResult.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/FieldParameterMember.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/FieldParameterMemberParser.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/JavaExecutable.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Parameter.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/ParameterMember.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Parameters.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Postprocessor.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Preprocessor.java (100%) rename {scijava-execute => execute}/src/main/java/org/scijava/execute/Runner.java (100%) rename {scijava-execute => execute}/src/test/java/org/scijava/execute/ExecutablesTest.java (100%) rename {scijava-execute => execute}/src/test/java/org/scijava/execute/RunnerTest.java (100%) rename {scijava-execute => execute}/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java (100%) rename {scijava-function => function}/LICENSE.txt (100%) rename {scijava-function => function}/README.md (100%) rename {scijava-function => function}/bin/generate.groovy (100%) rename {scijava-function => function}/pom.xml (100%) rename {scijava-function => function}/src/main/java/module-info.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Computers.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Consumers.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Container.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Functions.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Inplaces.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Mutable.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/Producer.java (100%) rename {scijava-function => function}/src/main/java/org/scijava/function/package-info.java (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Computers.list (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Computers.vm (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Consumers.list (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Consumers.vm (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Functions.list (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Functions.vm (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Globals.list (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Inplaces.list (100%) rename {scijava-function => function}/templates/main/java/org/scijava/function/Inplaces.vm (100%) rename {scijava-harvest => harvest}/pom.xml (100%) rename {scijava-harvest => harvest}/src/main/java/module-info.java (100%) rename {scijava-harvest => harvest}/src/main/java/org/scijava/harvest/Group.java (100%) rename {scijava-harvest => harvest}/src/main/java/org/scijava/harvest/Groups.java (100%) rename {scijava-harvest => harvest}/src/main/java/org/scijava/harvest/ObservableStruct.java (100%) rename {scijava-harvest => harvest}/src/main/java/org/scijava/harvest/ParameterModel.java (100%) rename {scijava-harvest => harvest}/src/main/java/org/scijava/harvest/ParameterNode.java (100%) rename {scijava-harvest => harvest}/src/main/java/org/scijava/harvest/ParameterTree.java (100%) rename {scijava-harvest => harvest}/src/test/java/org/scijava/harvest/ParameterModelTest.java (100%) rename {scijava-harvest => harvest}/src/test/java/org/scijava/harvest/ParameterTreeTest.java (100%) rename {scijava-imagej-compat => imagej-compat}/pom.xml (100%) rename {scijava-imagej-compat => imagej-compat}/src/main/java/module-info.java (100%) rename {scijava-imagej-compat => imagej-compat}/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java (100%) rename {scijava-imagej-compat => imagej-compat}/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java (100%) rename {scijava-index => index}/pom.xml (100%) rename {scijava-index => index}/src/main/java/module-info.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/AbstractIndexWriter.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/AnnotationProcessor.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/ByteCodeAnalyzer.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/DirectoryIndexer.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/Index.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/IndexDiscoverer.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/IndexItem.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/IndexReader.java (100%) rename {scijava-index => index}/src/main/java/org/scijava/index/Indexable.java (100%) rename {scijava-index => index}/src/main/resources/META-INF/services/javax.annotation.processing.Processor (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/AnnotationProcessorTest.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/Circle.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/Color.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/IndexDiscovererTest.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/Red.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/Shape.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/Square.java (100%) rename {scijava-index => index}/src/test/java/org/scijava/index/Widget.java (100%) rename {scijava-io3 => io3}/pom.xml (100%) rename {scijava-io3 => io3}/src/main/java/module-info.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/ByteArrayByteBank.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/ByteBank.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/ByteBufferByteBank.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/Bytes.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/BytesHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/DataHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/DataHandles.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/DummyHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/FileHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/StreamHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/AbstractLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/BrowsableLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/BytesLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/DummyLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/FileLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/FileLocationResolver.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/Location.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/LocationResolver.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/Locations.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/RemoteLocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/URILocation.java (100%) rename {scijava-io3 => io3}/src/main/java/org/scijava/io3/location/URLLocation.java (100%) rename {scijava-io3 => io3}/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver (100%) rename {scijava-io3 => io3}/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java (100%) rename {scijava-io3 => io3}/src/test/java/org/scijava/io3/handle/DataHandlesTest.java (100%) rename {scijava-io3 => io3}/src/test/java/org/scijava/io3/location/LocationsTest.java (100%) rename {scijava-javafx => javafx}/pom.xml (100%) rename {scijava-javafx => javafx}/src/main/java/module-info.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxChoice.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxDialog.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxFile.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxInputHarvester.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxMenus.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxMessage.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxNumber.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxPanel.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxText.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxThread.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxToggle.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxWidget.java (100%) rename {scijava-javafx => javafx}/src/main/java/org/scijava/javafx/FxWidgetFactory.java (100%) rename {scijava-javafx => javafx}/src/test/java/org/scijava/javafx/FxMenusTest.java (100%) rename {scijava-javafx => javafx}/src/test/java/org/scijava/javafx/FxShell.java (100%) rename {scijava-javafx => javafx}/src/test/java/org/scijava/javafx/FxWidgetsTest.java (100%) rename {scijava-javafx => javafx}/src/test/resources/scripts/Process/Filters/Blur_It.groovy (100%) rename {scijava-javafx => javafx}/src/test/resources/scripts/Say_Hello.groovy (100%) rename {scijava-meta => meta}/.gitignore (100%) rename {scijava-meta => meta}/LICENSE.txt (100%) rename {scijava-meta => meta}/README.md (100%) rename {scijava-meta => meta}/pom.xml (100%) rename {scijava-meta => meta}/src/main/java/module-info.java (100%) rename {scijava-meta => meta}/src/main/java/org/scijava/meta/Manifest.java (100%) rename {scijava-meta => meta}/src/main/java/org/scijava/meta/POM.java (100%) rename {scijava-meta => meta}/src/main/java/org/scijava/meta/Versions.java (100%) rename {scijava-meta => meta}/src/main/java/org/scijava/meta/XML.java (100%) rename {scijava-meta => meta}/src/test/java/org/scijava/meta/POMTest.java (100%) rename {scijava-meta => meta}/src/test/java/org/scijava/meta/VersionsTest.java (100%) rename {scijava-ops-api => ops-api}/.gitignore (100%) rename {scijava-ops-api => ops-api}/LICENSE.txt (100%) rename {scijava-ops-api => ops-api}/README.md (100%) rename {scijava-ops-api => ops-api}/bin/generate.groovy (100%) rename {scijava-ops-api => ops-api}/pom.xml (100%) rename {scijava-ops-api => ops-api}/src/main/java/module-info.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/Hints.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/InfoTree.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpBuilder.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpEnvironment.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpHistory.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpInfo.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpInstance.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpMatchingException.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/OpRequest.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/Ops.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/RichOp.java (100%) rename {scijava-ops-api => ops-api}/src/main/java/org/scijava/ops/api/package-info.java (100%) rename {scijava-ops-api => ops-api}/templates/main/java/org/scijava/ops/api/Globals.list (100%) rename {scijava-ops-api => ops-api}/templates/main/java/org/scijava/ops/api/OpBuilder.list (100%) rename {scijava-ops-api => ops-api}/templates/main/java/org/scijava/ops/api/OpBuilder.vm (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/.gitignore (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/LICENSE.txt (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/README.md (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/pom.xml (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/src/main/java/org/scijava/ops/benchmarks/BenchmarkState.java (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/src/main/java/org/scijava/ops/benchmarks/Benchmarks.java (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/src/main/java/org/scijava/ops/benchmarks/OpsToBenchmark.java (100%) rename {scijava-ops-benchmarks => ops-benchmarks}/src/test/java/org/scijava/ops/benchmarks/matching/OpsToBenchmarkTest.java (100%) rename {scijava-ops-compat => ops-compat}/LICENSE.txt (100%) rename {scijava-ops-compat => ops-compat}/README.md (100%) rename {scijava-ops-compat => ops-compat}/pom.xml (100%) rename {scijava-ops-compat => ops-compat}/src/main/java/module-info.java (100%) rename {scijava-ops-compat => ops-compat}/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java (100%) rename {scijava-ops-compat => ops-compat}/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java (100%) rename {scijava-ops-compat => ops-compat}/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java (100%) rename {scijava-ops-compat => ops-compat}/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor (100%) rename {scijava-ops-compat => ops-compat}/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java (100%) rename {scijava-ops-compat => ops-compat}/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java (100%) rename {scijava-ops-engine => ops-engine}/.gitignore (100%) rename {scijava-ops-engine => ops-engine}/LICENSE.txt (100%) rename {scijava-ops-engine => ops-engine}/README.md (100%) rename {scijava-ops-engine => ops-engine}/bin/generate.groovy (100%) rename {scijava-ops-engine => ops-engine}/pom.xml (100%) rename {scijava-ops-engine => ops-engine}/src/it/settings.xml (100%) rename {scijava-ops-engine => ops-engine}/src/it/test-ops-api/pom.xml (100%) rename {scijava-ops-engine => ops-engine}/src/it/test-ops-api/src/main/java/OpsAPITest.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/module-info.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/BaseOpHints.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/DependencyMatchingException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/InfoTreeGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/MatchingConditions.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/OpDependencyMember.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/OpDescriptionGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/OpInfoGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/OpWrapper.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/conversionLoss/LossReporter.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/conversionLoss/LosslessReporter.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/conversionLoss/impl/IdentityLossReporter.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveArrayLossReporters.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/conversionLoss/impl/PrimitiveLossReporters.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/conversionLoss/package-info.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/copy/CopyOpCollection.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/create/CreateOpCollection.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/describe/BaseDescriptors.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/describe/TypeDescriptor.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/eval/DefaultEval.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/eval/OpEvaluator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/InvalidOpException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/FinalOpDependencyFieldException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/FunctionalTypeOpException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/InstanceOpMethodException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/InvalidOpNameException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/MultipleOutputsOpException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/NullablesOnMultipleMethodsException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/OpDependencyPositionException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/PrivateOpException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/UnnamedOpException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/exceptions/impl/UnreadableOpException.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/DefaultInfoTreeGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/DefaultOpEnvironment.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/DefaultOpHistory.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/DependencyOpInstanceInfoTree.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/DependencyRichOpInfoTree.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/LambdaTypeBaker.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/OpClassOpInfoGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/impl/OpCollectionInfoGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/MatchingResult.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/MatchingRoutine.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/OpCandidate.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/OpMatcher.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationInfoTreeGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/adapt/AdaptationMatchingRoutine.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/ConversionMatchingRoutine.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/Conversions.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedInfoTreeGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/ConvertedOpInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/IdentityCollection.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/ListConverterCollection.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverters.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/PrimitiveConverters.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/convert/UtilityConverters.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/AbstractRichOp.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpClassInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMatcher.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpMethodInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/DefaultOpRequest.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/InfoMatchingOpRequest.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/LossReporterWrapper.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/MatchingUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/impl/RuntimeSafeMatchingRoutine.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/AbstractInfoReducer.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/ComputerReducer.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/FunctionReducer.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/InfoReducer.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedInfoTreeGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/ReducedOpInfoGenerator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/matcher/reduce/ReductionUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/Add.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/MathOpCollection.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/MathOps.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/Normalize.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/Power.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/Sqrt.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/math/Zero.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/package-info.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/stats/Mean.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/stats/Size.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/AnnotatedOpDependencyMember.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/ClassOpDependencyMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/ClassParameterMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/ConvertedParameterMember.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/FieldInstance.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/FieldOpDependencyMember.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/FieldParameterMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/FunctionalMethodType.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/FunctionalParameters.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/MethodOpDependencyMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/MethodParameterMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/MethodParameterOpDependencyMember.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/OpResizingMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/OpRetypingMemberParser.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/ParameterData.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/RetypingRequest.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/SynthesizedFieldParameterData.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/SynthesizedMethodParameterData.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/struct/SynthesizedParameterMember.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/FunctionUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/Infos.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/Lambdas.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/Maps.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/internal/AnnotationUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/internal/OpMethodUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/internal/package-info.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/util/package-info.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/YAMLOpInfoCreator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/YAMLUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/AbstractYAMLOpInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/JavaClassYAMLOpInfoCreator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/JavaFieldYAMLOpInfoCreator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/JavaMethodYAMLInfoCreator.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpClassInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpFieldInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpInfoDiscoverer.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/java/org/scijava/ops/engine/yaml/impl/YAMLOpMethodInfo.java (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.discovery.Discoverer (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.api.OpEnvironment (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.api.OpHistory (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.InfoTreeGenerator (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.OpDescriptionGenerator (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.OpInfoGenerator (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.OpWrapper (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.MatchingRoutine (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.matcher.reduce.InfoReducer (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.engine.yaml.YAMLOpInfoCreator (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.spi.Op (100%) rename {scijava-ops-engine => ops-engine}/src/main/resources/META-INF/services/org.scijava.ops.spi.OpCollection (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/AbstractTestEnvironment.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/InfoTreeGeneratorTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/InfoTreeTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpBuilderNoOutputTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpBuilderTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpBuilderTestOps.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpEnvironmentTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpInfoTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpMethodBoxTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpMethodInParentTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpMethodTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpMethodTestOps.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/OpPriorityTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/TestOps.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/TestUtils.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/AdaptationTypeVariableCaptureTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/OpAdaptationPriorityTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionObjectOutputTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/functional/PermutedComputerToFunctionAdaptTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/copy/CopyOpCollectionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/describe/BaseDescriptorsTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/eval/EvalTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/hints/AdaptationHintTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/hints/ConversionHintTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/DefaultOpDescriptionGeneratorTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/LambdaTypeBakerTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/OpCachingTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/OpMethodDependencyPositionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/ProvenanceTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/ServiceLoaderDiscoveryIntegrationTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/impl/UnnamedOpExceptionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/MatchingWithAnyTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/MatchingWithGCSTTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/OpMatchingExceptionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/RawTypeMatchingTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/adapt/OpAdaptationInfoTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionAdaptationTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionIOTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPathwayIgnoreTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionPriorityTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/ConversionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveArrayConverterTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/convert/PrimitiveListConverterTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/impl/DefaultOpFieldInfoTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/impl/MatchingUtilsTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/impl/OpClassInfoTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/impl/OpDescriptionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/matcher/impl/OpMethodInfoTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/math/MathOpCollectionTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/reduce/BiFunctionWithNullable.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsFromIFaceTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/reduce/NullableArgumentsTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/reduce/ReductionWithDependenciesTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableArg.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/reduce/TestOpNullableFromIFace.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/stats/MeanTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/stats/SizeTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/ComputersTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/FunctionsTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/InfosTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/InplacesTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/LambdaizerTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/LiftTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/util/OpsAsParametersTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/yaml/impl/YAMLOpTest.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLClassOp.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLFieldOp.java (100%) rename {scijava-ops-engine => ops-engine}/src/test/java/org/scijava/ops/engine/yaml/impl/ops/YAMLMethodOp.java (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/module-info.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/module-info.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/complexLift/ComputersToFunctionsAndLift.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/complexLift/FunctionsToComputersAndLift.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaFunction.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/ComputersToFunctionsViaSource.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/FunctionsToComputers.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/functional/InplacesToFunctions.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToArrays.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/ComputerToIterables.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToArrays.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/FunctionToIterables.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/adapt/lift/InplaceToArrays.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/matcher/impl/OpWrappers.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.list (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/util/FunctionUtils.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/main/java/org/scijava/ops/engine/util/Globals.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpBuilderTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpBuilderTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpBuilderTestOps.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpMethodTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpMethodTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/OpMethodTestOps.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/complexLift/ComputerToFunctionIterablesTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/complexLift/FunctionToComputerIterablesTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/ComputerToFunctionAdaptTestOps.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/FunctionToComputerAdaptTestOps.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/functional/InplaceToFunctionAdaptTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToArraysTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/ComputerToIterablesTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToArraysTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/FunctionToIterablesTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/adapt/lift/InplaceToArraysTest.vm (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.list (100%) rename {scijava-ops-engine => ops-engine}/templates/test/java/org/scijava/ops/engine/matcher/OpWrappersTest.vm (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/.gitignore (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/LICENSE.txt (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/NOTICE.txt (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/README.md (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/pom.xml (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/src/main/java/module-info.java (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/src/main/java/org/scijava/ops/parser/InvalidOpException.java (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/src/main/java/org/scijava/ops/parser/OpParameter.java (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/src/main/java/org/scijava/ops/parser/OpParser.java (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/src/test/java/org/scijava/ops/parser/TestOpParser.java (100%) rename {scijava-ops-ext-parser => ops-ext-parser}/src/test/resources/test-ops.yaml (100%) rename {scijava-ops-flim => ops-flim}/LICENSE.txt (100%) rename {scijava-ops-flim => ops-flim}/README.md (100%) rename {scijava-ops-flim => ops-flim}/environment.yml (100%) rename {scijava-ops-flim => ops-flim}/images/example A.png (100%) rename {scijava-ops-flim => ops-flim}/images/example tau.png (100%) rename {scijava-ops-flim => ops-flim}/images/example z.png (100%) rename {scijava-ops-flim => ops-flim}/notebooks/Demo.ipynb (100%) rename {scijava-ops-flim => ops-flim}/notebooks/README.md (100%) rename {scijava-ops-flim => ops-flim}/pom.xml (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/AbstractFitRAI.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/AmplitudeFractions.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/FitParams.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/FitResults.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/FitWorker.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/MeanLifetimes.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/ParamEstimator.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/Pseudocolor.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/AbstractFitWorker.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/AbstractSingleFitWorker.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/BayesFit.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/Creators.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/GlobalFit.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/LMAFit.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/PhasorFit.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/impl/RLDFit.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/types/FitParamsTypeExtractor.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/java/org/scijava/ops/flim/util/RAHelper.java (100%) rename {scijava-ops-flim => ops-flim}/src/main/resources/META-INF/services/org.scijava.types.TypeExtractor (100%) rename {scijava-ops-flim => ops-flim}/src/test/java/org/scijava/ops/flim/AbstractFlimTest.java (100%) rename {scijava-ops-flim => ops-flim}/src/test/java/org/scijava/ops/flim/AmplitudeFractionsTest.java (100%) rename {scijava-ops-flim => ops-flim}/src/test/java/org/scijava/ops/flim/FitTest.java (100%) rename {scijava-ops-flim => ops-flim}/src/test/java/org/scijava/ops/flim/MeanLifetimesTest.java (100%) rename {scijava-ops-flim => ops-flim}/src/test/java/org/scijava/ops/flim/MiscTest.java (100%) rename {scijava-ops-flim => ops-flim}/test_files/cells.sdt (100%) rename {scijava-ops-flim => ops-flim}/test_files/input.sdt (100%) rename {scijava-ops-flim => ops-flim}/test_files/test2.sdt (100%) rename {scijava-ops-image => ops-image}/LICENSE.txt (100%) rename {scijava-ops-image => ops-image}/README.md (100%) rename {scijava-ops-image => ops-image}/bin/generate.groovy (100%) rename {scijava-ops-image => ops-image}/pom.xml (100%) rename {scijava-ops-image => ops-image}/src/main/java/module-info.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/About.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/LiftNeighborhoodComputersToRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/RAIToIIOps.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/adapt/complexLift/LiftNeighborhoodComputersToFunctionsOnImgs.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/ColocUtil.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/IntArraySorter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/IntComparator.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/MergeSort.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/ShuffledView.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/icq/LiICQ.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRank.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKT.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/pValue/DefaultPValue.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/pValue/PValueResult.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsons.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/saca/AdaptiveSmoothedKendallTau.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/saca/SACAHeatmapZScore.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/saca/SACASigMask.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/coloc/saca/WtKendallTau.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/ConvertComplexTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/ConvertImages.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/ConvertNumbersToRealTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/Converters.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/Types.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/clip/ClipRealTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/convert/copy/CopyComplexTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/copy/Copiers.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/CreateTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/Creators.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernel2ndDerivBiGauss.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelBiGauss.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGabor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGauss.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelGibsonLanni.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelLog.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/DefaultCreateKernelSobel.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/create/Imgs.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/AccelerationState.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantFirstGuess.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/NonCirculantNormalizationFactor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/PadAndRichardsonLucyTV.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyC.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyCorrection.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyTVUpdate.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/RichardsonLucyUpdate.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/deconvolve/VectorAccelerator.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/describe/ImgLib2Descriptors.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/AbstractHaralickFeature.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultASM.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterProminence.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultClusterShade.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultContrast.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultCorrelation.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceEntropy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultDifferenceVariance.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultEntropy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM1.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultICM2.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultIFDM.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultMaxProbability.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumAverage.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumEntropy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultSumVariance.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultTextureHomogeneity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/DefaultVariance.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocHXY.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanX.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocMeanY.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPX.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXMinusY.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPXPlusY.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocPY.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdX.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/haralick/helper/CoocStdY.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/lbp2d/DefaultLBP2D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultCoarsenessFeature.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultContrastFeature.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/tamura2d/DefaultDirectionalityFeature.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/zernike/DefaultMagnitudeFeature.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/zernike/DefaultPhaseFeature.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/zernike/Polynom.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/zernike/ZernikeComputer.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/features/zernike/ZernikeMoment.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/CenterAwareNeighborhoodBasedFilter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/FFTMethodsLinearFFTFilterC.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/NeighborhoodFilters.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/addNoise/NoiseAdders.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/bilateral/DefaultBilateral.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTC.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/convolve/ConvolveFFTF.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/convolve/NaiveConvolutions.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTC.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/correlate/CorrelateFFTF.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativeRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/derivative/PartialDerivativesRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGauss.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/dog/DefaultDoG.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/dog/DoGVaryingSigmas.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/fft/FFTs.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTMethodsSize.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/fftSize/ComputeFFTSize.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/fftSize/DefaultComputeFFTSize.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/fftSize/NextPowerOfTwo.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumber.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/gauss/Gaussians.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/hessian/HessianRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpC.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/ifft/IFFTMethodsOpI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadInputFFT.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/DefaultPadShiftKernelFFT.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PadInput.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFT.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PadInputFFTMethods.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernel.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PadShiftKernelFFTMethods.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalCentered.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/pad/PaddingIntervalOrigin.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/sigma/DefaultSigmaFilter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/sobel/SobelRAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/tubeness/DefaultTubeness.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/filter/vesselness/DefaultFrangi.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/AbstractBoundarySizeConvexHull.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/AbstractBoxivity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/AbstractConvexity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/AbstractSizeConvexHull.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/AbstractSolidity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/CentroidII.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/CentroidLabelRegion.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/CentroidPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/DefaultCenterOfGravity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/GeomUtils.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/SizeII.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundarySizeConvexHullPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoundingBox.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultBoxivityPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultCircularity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultContour.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexHull2D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultConvexityPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultEccentricity.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultElongation.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsAngle.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultFeretsDiameterForAngle.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMajorAxis.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeret.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretAngle.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMaximumFeretDiameter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeret.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretAngle.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinimumFeretDiameter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorAxis.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultMinorMajorAxis.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultPerimeterLength.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultRoundness.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizeConvexHullPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSizePolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSmallestEnclosingRectangle.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultSolidityPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountConvexHullPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom2d/DefaultVerticesCountPolygon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/DefaultInertiaTensor3D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/Facet.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/Horizon.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/TriangularFacet.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/UpdateablePointSet.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/Vertex.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/geom/geom3d/VertexInterpolator.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/ascii/DefaultASCII.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix2D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/CooccurrenceMatrix3D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation2D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/cooccurrenceMatrix/MatrixOrientation3D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/CalibratedDistanceTransformer.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransform.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformCalibration.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DCalibration.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3D.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DCalibration.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/distancetransform/DistanceTransformer.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/fill/Fills.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/histogram/HistogramCreate.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/integral/AbstractIntegralImg.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/integral/DefaultIntegralImg.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/integral/IntegralCursor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/integral/SquareIntegralImg.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/integral/WrappedIntegralImg.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/invert/InvertII.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/invert/InvertIIInteger.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/invert/Inverters.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIComputer.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIIFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/normalize/NormalizeIILazyFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/normalize/NormalizeRealTypeComputer.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/Watershed.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinary.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinaryFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigma.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeeded.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/image/watershed/WatershedSeededFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/AbstractImageMomentOp.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment00.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment01.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment02.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment03.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment10.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment11.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment12.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment20.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment21.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/DefaultCentralMoment30.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment00.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/centralmoments/IterableCentralMoment11.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment1.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment2.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment3.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment4.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment5.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment6.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/hu/DefaultHuMoment7.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment00.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment01.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment10.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/moments/DefaultMoment11.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment02.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment03.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment11.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment12.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment20.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment21.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/imagemoments/normalizedcentralmoments/DefaultNormalizedCentralMoment30.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/labeling/MergeLabeling.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/labeling/cca/DefaultCCA.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/linalg/rotate/Rotations.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/logic/BooleanTypeLogic.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/logic/Default.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/logic/Ternary.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/map/neighborhood/DefaultMapNeighborhood.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodWithCenter.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/math/BinaryNumericTypeMath.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/math/BinaryRealTypeMath.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/math/NullaryNumericTypeMath.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/math/UnaryRealTypeMath.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/math/multiply/ComplexConjugateMultiplyOp.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/BlackTopHats.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/Closes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/Dilations.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/DistanceTransforms.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/Erosions.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/ExtractHoles.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/FillHoles.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/FloodFills.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/Opens.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/Outline.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/Table2Ds.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/WhiteTopHats.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/Abstract3x3NeighbourhoodThinning.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/AbstractThin.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/GuoHallThinningStrategy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/HilditchThinningStrategy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/MorphologicalThinningStrategy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/ThinGuoHall.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/ThinHilditch.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/ThinMorphological.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/ThinZhangSuen.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/ThinningStrategy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/morphology/thin/ZhangSuenThinningStrategy.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctions.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidges.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionMetadata.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/segment/detectRidges/RidgeDetectionUtils.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultGeometricMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultHarmonicMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultKurtosis.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMax.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMedian.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMin.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMinMax.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMoment1AboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMoment2AboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMoment3AboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMoment4AboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultMomentNAboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultPNorm.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultPercentile.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultQNorm.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultQuantile.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultSize.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultSkewness.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultStandardDeviation.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultSum.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultSumOfInverses.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultSumOfLogs.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultSumOfSquares.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/DefaultVariance.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IntegralMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IntegralSum.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IntegralVariance.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableGeometricMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableHarmonicMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableMax.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableMin.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableMinMax.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableMoment3AboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableMoment4AboutMean.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableSize.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableStandardDeviation.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableSum.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableSumOfInverses.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableSumOfLogs.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableSumOfSquares.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/IterableVariance.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/stats/regression/leastSquares/Quadric.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/thread/chunker/Chunk.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/thread/chunker/ChunkerInterleaved.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/thread/chunker/CursorBasedChunk.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/thread/chunker/DefaultChunker.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/AbstractApplyThresholdImg.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/AbstractComputeThresholdHistogram.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/ApplyLocalThresholdIntegral.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethod.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/ApplyThresholdMethodLocal.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/Thresholds.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/apply/ApplyConstantThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparable.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/apply/ApplyThresholdComparator.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/huang/ComputeHuangThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/ij1/ComputeIJ1Threshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/intermodes/ComputeIntermodesThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/isoData/ComputeIsoDataThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/li/ComputeLiThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localBernsen/ComputeLocalBernsenThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localBernsen/LocalBernsenThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localContrast/ComputeLocalContrastThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localContrast/LocalContrastThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMean/ComputeLocalMeanThresholdIntegral.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMean/LocalMeanThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMedian/ComputeLocalMedianThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMedian/LocalMedianThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMidGrey/ComputeLocalMidGreyThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localMidGrey/LocalMidGreyThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localNiblack/ComputeLocalNiblackThresholdIntegral.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localNiblack/LocalNiblackThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/ComputeLocalPhansalkarThresholdIntegral.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localPhansalkar/LocalPhansalkarThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localSauvola/ComputeLocalSauvolaThresholdIntegral.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/localSauvola/LocalSauvolaThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/maxEntropy/ComputeMaxEntropyThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/maxLikelihood/ComputeMaxLikelihoodThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/mean/ComputeMeanThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/minError/ComputeMinErrorThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/minimum/ComputeMinimumThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/moments/ComputeMomentsThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/otsu/ComputeOtsuThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/percentile/ComputePercentileThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/renyiEntropy/ComputeRenyiEntropyThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/rosin/ComputeRosinThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/shanbhag/ComputeShanbhagThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/triangle/ComputeTriangleThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/threshold/yen/ComputeYenThreshold.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/topology/BoxCount.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26N.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloating.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrection.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/topology/eulerCharacteristic/Octant.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/transform/DefaultTransformView.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/transform/Transforms.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelComputer.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/transform/project/project/ProjectParallelFunction.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/transform/slice/slice/SliceRAI2RAI.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/transform/slice/slice/SlicesII.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/ExtendedRAITypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/Histogram1dTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/ImgFactoryTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/ImgLabelingTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/LabelingMappingTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/NativeImgTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/OutOfBoundsConstantValueFactoryTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/OutOfBoundsFactoryTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/OutOfBoundsRandomValueFactoryTypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/RAITypeExtractor.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/types/minValue/MinValueRealTypes.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/util/BigComplex.java (100%) rename {scijava-ops-image => ops-image}/src/main/java/org/scijava/ops/image/util/UnboundedIntegerType.java (100%) rename {scijava-ops-image => ops-image}/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/AbstractColocalisationTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/AbstractFeatureTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/AbstractOpTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/OpRegressionTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/adapt/LiftFunctionsToImgTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/MergeSortTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/ShuffledViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/icq/LiICQTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/kendallTau/KendallTauBRankTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/maxTKendallTau/MTKTTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/pValue/DefaultPValueTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/pearsons/DefaultPearsonsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/coloc/saca/SACATest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/convert/ConvertIIsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/convert/ConvertMapTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/convert/RAIConvertersTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/convert/TestConvertImages.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/convert/TestConvertNumbersToRealTypes.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyIITest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyImgLabelingTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyImgTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyImgsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyLabelingMappingTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyRAITest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/copy/CopyTypeTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateImgTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateKernel2ndDerivBiGaussTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateKernelBiGaussTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateKernelDiffractionTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateKernelGaborTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateKernelGaussTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateKernelLogTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateLabelingTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/create/CreateNativeTypeTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/deconvolve/DeconvolveTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/describe/ImgLib2DescriptorsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/features/haralick/HaralickFeatureTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/features/hog/HistogramOfOrientedGradients2DTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/features/lbp2d/LBP2dFeatureTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/features/tamura2d/Tamura2dFeatureTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/features/zernike/ZernikeFeatureTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/FFTTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/NonLinearFiltersTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/addNoise/NoiseAddersTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/bilateral/DefaultBilateralTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/convolve/ConvolveTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/correlate/CorrelateTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/derivative/PartialDerivativeFilterTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/derivativeGauss/DefaultDerivativeGaussTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/dog/DoGTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/fftSize/NextSmoothNumberTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/gauss/GaussTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/hessian/HessianFilterTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/mean/MeanFilterTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/sobel/SobelFilterTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/tubeness/TubenessTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/filter/vesselness/FrangiVesselnessTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/geom/geom2d/PolygonFeatureTests.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/geom/geom3d/MeshFeatureTests.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/geom/geom3d/QuickHull3DTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/ascii/ASCIITest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/distancetransform/DefaultDistanceTransformTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform2DTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/distancetransform/DistanceTransform3DTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/fill/FillTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/histogram/DefaultHistogramTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/integral/IntegralCursorTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/integral/IntegralImgTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/integral/SquareIntegralImgTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/invert/InvertTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/normalize/NormalizeTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinarySingleSigmaTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/watershed/WatershedBinaryTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/watershed/WatershedSeededTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/image/watershed/WatershedTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/imagemoments/ImageMomentsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/labeling/MergeLabelingTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3dTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/linalg/rotate/Rotate3fTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/logic/BooleanTypeLogicTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/logic/ConditionalTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/map/neighborhood/MapNeighborhoodTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/math/BinaryNumericTypeMathTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/math/BinaryRealTypeMathTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/math/ImageBinaryMathTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/math/UnaryRealTypeMathTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/math/add/AddNumericTypeBinaryMathAddTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/morphology/MorphologyOpsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/morphology/blackTopHat/BlackTopHatTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/morphology/outline/OutlineTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/morphology/thin/ThinningTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/segment/detectJunctions/DefaultDetectJunctionsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/segment/detectRidges/DefaultDetectRidgesTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/stats/DefaultMedianTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/stats/DefaultPNormTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/stats/DefaultQNormTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/stats/StatisticsTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/stats/regression/leastSquares/QuadricTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/threshold/AbstractThresholdTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/threshold/ComputeThresholdTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/threshold/apply/ApplyConstantThresholdTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/threshold/apply/LocalThresholdTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/topology/BoxCountTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NFloatingTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCharacteristic26NTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/EulerCorrectionTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/OctantTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/topology/eulerCharacteristic/TestHelper.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/addDimensionView/AddDimensionViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/collapseNumericView/CollapseNumericViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/collapseRealView/CollapseRealViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/collapseView/CollapseViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/concatenateView/ConcatenateViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/dropSingleDimensionsView/DropSingletonDimensionsViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendBorderView/ExtendBorderViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendMirrorDoubleView/ExtendMirrorDoubleViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendMirrorSingleView/ExtendMirrorSingleViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendPeriodicView/ExtendPeriodicViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendRandomView/ExtendRandomViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendValueView/ExtendValueViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendView/ExtendViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/extendZeroView/ExtendZeroViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/flatIterableView/FlatIterableViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/hyperSliceView/HyperSliceViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/interpolateView/InterpolateViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/intervalView/IntervalViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/invertAxisView/InvertAxisViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/offsetView/OffsetViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/permuteView/PermuteViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/project/ProjectTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/rasterView/RasterViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/realTransform/RealTransformTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/rotateView/RotateViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/shearView/ShearViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/slice/slice/SliceTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/stackView/StackViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/subsampleView/SubsampleViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/translateView/TranslateViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/unshearView/UnshearViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/transform/zeroMinView/ZeroMinViewTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/types/TypeExtractorTests.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/types/adapt/LiftComputersToRAITest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/types/adapt/LiftFunctionsToRAITest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/types/adapt/LiftNeighborhoodComputersToRAITest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/types/maxValue/MaxValueRealTypesTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/types/minValue/MinValueRealTypesTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/util/BigComplexTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/util/TestImgGeneration.java (100%) rename {scijava-ops-image => ops-image}/src/test/java/org/scijava/ops/image/util/TestImgGenerationTest.java (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-green.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/coloc/colocsample1b-red.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/coloc/greenZstack.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/coloc/redZstack.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_polygon.txt (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/2d_geometric_features_testlabel.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_mesh.txt (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/3d_geometric_features_testlabel.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/haralick_test_img.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/hog/HoG2DInput.png (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/features/hog/HoG2DResult.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/filter/tubeness/TubesInput.png (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/filter/tubeness/tube.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/filter/vesselness/Result.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/image/watershed/watershed_test_image.png (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/img_with_holes.png (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/img_without_holes.png (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/inverted_img_with_filled_holes.png (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/thin/result_guoHall.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/thin/result_hilditch.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/thin/result_morphological.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/morphology/thin/result_zhangSuen.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/transform/realTransform/lowresbridge.tif (100%) rename {scijava-ops-image => ops-image}/src/test/resources/org/scijava/ops/image/transform/realTransform/rotatedscaledcenter.tif (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/Globals.list (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.list (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/LiftComputersToRAI.vm (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.list (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/LiftFunctionsToRAI.vm (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.list (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/complexLift/ComputersToFunctionsAndLift.vm (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.list (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/complexLift/FunctionsToComputersAndLift.vm (100%) rename {scijava-ops-image => ops-image}/templates/main/java/org/scijava/ops/image/adapt/complexLift/Globals.list (100%) rename {scijava-ops-image => ops-image}/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.list (100%) rename {scijava-ops-image => ops-image}/templates/test/java/org/scijava/ops/image/convert/RAIConvertersTest.vm (100%) rename {scijava-ops-image => ops-image}/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.list (100%) rename {scijava-ops-image => ops-image}/templates/test/java/org/scijava/ops/image/convert/TestConvertComplexTypes.vm (100%) rename {scijava-ops-image => ops-image}/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.list (100%) rename {scijava-ops-image => ops-image}/templates/test/java/org/scijava/ops/image/convert/TestConvertImages.vm (100%) rename {scijava-ops-indexer => ops-indexer}/.gitignore (100%) rename {scijava-ops-indexer => ops-indexer}/LICENSE.txt (100%) rename {scijava-ops-indexer => ops-indexer}/NOTICE.txt (100%) rename {scijava-ops-indexer => ops-indexer}/README.md (100%) rename {scijava-ops-indexer => ops-indexer}/pom.xml (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/module-info.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/InvalidOpImplException.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/OpClassImplData.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/OpFieldImplData.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/OpImplData.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/OpImplNoteParser.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/OpMethodImplData.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/OpParameter.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/java/org/scijava/ops/indexer/ProcessingUtils.java (100%) rename {scijava-ops-indexer => ops-indexer}/src/main/resources/META-INF/services/javax.annotation.processing.Processor (100%) rename {scijava-ops-opencv => ops-opencv}/LICENSE.txt (100%) rename {scijava-ops-opencv => ops-opencv}/README.md (100%) rename {scijava-ops-opencv => ops-opencv}/opencv-ops.yaml (100%) rename {scijava-ops-opencv => ops-opencv}/pom.xml (100%) rename {scijava-ops-opencv => ops-opencv}/src/main/java/module-info.java (100%) rename {scijava-ops-opencv => ops-opencv}/src/main/java/org/scijava/ops/opencv/MatCopier.java (100%) rename {scijava-ops-opencv => ops-opencv}/src/main/java/org/scijava/ops/opencv/OpenCVDescriptors.java (100%) rename {scijava-ops-opencv => ops-opencv}/src/main/resources/ops.yaml (100%) rename {scijava-ops-opencv => ops-opencv}/src/test/java/org/scijava/ops/opencv/TestOpenCV.java (100%) rename {scijava-ops-opencv => ops-opencv}/src/test/resources/HappyFish.jpg (100%) rename {scijava-ops-spi => ops-spi}/.gitignore (100%) rename {scijava-ops-spi => ops-spi}/LICENSE.txt (100%) rename {scijava-ops-spi => ops-spi}/README.md (100%) rename {scijava-ops-spi => ops-spi}/pom.xml (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/module-info.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/Nullable.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/Op.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpClass.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpCollection.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpDependency.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpExecutionException.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpField.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpHints.java (100%) rename {scijava-ops-spi => ops-spi}/src/main/java/org/scijava/ops/spi/OpMethod.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/LICENSE.txt (100%) rename {scijava-ops-tutorial => ops-tutorial}/README.md (100%) rename {scijava-ops-tutorial => ops-tutorial}/environment.yml (100%) rename {scijava-ops-tutorial => ops-tutorial}/notebooks/SciJavaOps.ipynb (100%) rename {scijava-ops-tutorial => ops-tutorial}/notebooks/gateways.py (100%) rename {scijava-ops-tutorial => ops-tutorial}/pom.xml (100%) rename {scijava-ops-tutorial => ops-tutorial}/resources/ops-dot-math.png (100%) rename {scijava-ops-tutorial => ops-tutorial}/resources/ops-dot.png (100%) rename {scijava-ops-tutorial => ops-tutorial}/scripts/gateways.py (100%) rename {scijava-ops-tutorial => ops-tutorial}/scripts/ops-setup.py (100%) rename {scijava-ops-tutorial => ops-tutorial}/scripts/services-test.py (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/module-info.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpAdaptation.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpBuilder.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpConversion.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpDependencies.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpParallelization.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpPriorities.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpReduction.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpTypes.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/OpsIntro.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/ParallelComputation.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/ReportingProgress.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/UsingNils.java (100%) rename {scijava-ops-tutorial => ops-tutorial}/src/main/java/org/scijava/ops/tutorial/WritingConciseOps.java (100%) rename {scijava-priority => priority}/.gitignore (100%) rename {scijava-priority => priority}/LICENSE.txt (100%) rename {scijava-priority => priority}/README.md (100%) rename {scijava-priority => priority}/pom.xml (100%) rename {scijava-priority => priority}/src/main/java/module-info.java (100%) rename {scijava-priority => priority}/src/main/java/org/scijava/priority/Prioritized.java (100%) rename {scijava-priority => priority}/src/main/java/org/scijava/priority/Priority.java (100%) rename {scijava-priority => priority}/src/test/java/org/scijava/priority/PrioritizedTest.java (100%) rename {scijava-progress => progress}/.gitignore (100%) rename {scijava-progress => progress}/LICENSE.txt (100%) rename {scijava-progress => progress}/README.md (100%) rename {scijava-progress => progress}/pom.xml (100%) rename {scijava-progress => progress}/src/main/java/module-info.java (100%) rename {scijava-progress => progress}/src/main/java/org/scijava/progress/Progress.java (100%) rename {scijava-progress => progress}/src/main/java/org/scijava/progress/Task.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/CancellationTest.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/DefaultProgressTest.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/ImproperReportingTest.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/ParallelProgressTest.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/StatusBasedProgressTest.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/SubtaskProgressTest.java (100%) rename {scijava-progress => progress}/src/test/java/org/scijava/progress/TestSuiteTaskConsumer.java (100%) rename {scijava-script3 => script3}/pom.xml (100%) rename {scijava-script3 => script3}/src/main/java/module-info.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/JsrLanguage.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/ScriptException.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/ScriptExecutable.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/ScriptFinder.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/ScriptHeader.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/ScriptLanguage.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/ScriptSession.java (100%) rename {scijava-script3 => script3}/src/main/java/org/scijava/script3/Scripts.java (100%) rename {scijava-script3 => script3}/src/test/java/org/scijava/script3/GroovyScriptTest.java (100%) rename {scijava-script3 => script3}/src/test/java/org/scijava/script3/ScriptFinderTest.java (100%) rename {scijava-script3 => script3}/src/test/java/org/scijava/script3/ScriptHeaderTest.java (100%) rename {scijava-script3 => script3}/src/test/resources/scripts/Process/Filters/Blur_It.groovy (100%) rename {scijava-script3 => script3}/src/test/resources/scripts/Say_Hello.groovy (100%) rename {scijava-settings => settings}/pom.xml (100%) rename {scijava-settings => settings}/src/main/java/module-info.java (100%) rename {scijava-settings => settings}/src/main/java/org/scijava/settings/LoadInputs.java (100%) rename {scijava-settings => settings}/src/main/java/org/scijava/settings/RememberedInputs.java (100%) rename {scijava-settings => settings}/src/main/java/org/scijava/settings/SaveInputs.java (100%) rename {scijava-settings => settings}/src/main/java/org/scijava/settings/Settings.java (100%) rename {scijava-settings => settings}/src/main/java/org/scijava/settings/Toml.java (100%) rename {scijava-settings => settings}/src/main/java/org/scijava/settings/TomlException.java (100%) rename {scijava-settings => settings}/src/test/java/org/scijava/settings/RememberedInputsTest.java (100%) rename {scijava-settings => settings}/src/test/java/org/scijava/settings/SettingsTest.java (100%) rename {scijava-settings => settings}/src/test/java/org/scijava/settings/TomlTest.java (100%) rename {scijava-spi => spi}/pom.xml (100%) rename {scijava-spi => spi}/src/main/java/module-info.java (100%) rename {scijava-spi => spi}/src/main/java/org/scijava/spi/Disposable.java (100%) rename {scijava-spi => spi}/src/main/java/org/scijava/spi/GenericTyped.java (100%) rename {scijava-spi => spi}/src/main/java/org/scijava/spi/Identifiable.java (100%) rename {scijava-spi => spi}/src/main/java/org/scijava/spi/Named.java (100%) rename {scijava-spi => spi}/src/main/java/org/scijava/spi/Versioned.java (100%) rename {scijava-struct => struct}/LICENSE.txt (100%) rename {scijava-struct => struct}/README.md (100%) rename {scijava-struct => struct}/pom.xml (100%) rename {scijava-struct => struct}/src/main/java/module-info.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/ItemIO.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/Member.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/MemberInstance.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/MemberParser.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/Struct.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/StructInstance.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/Structs.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/ValueAccessible.java (100%) rename {scijava-struct => struct}/src/main/java/org/scijava/struct/ValueAccessibleMemberInstance.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/AnnotatedParameterMember.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/Attr.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/DefaultMutableParameterMember.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/FieldParameterMember.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/FunctionalParameterMember.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/ItemVisibility.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/MutableParameterMember.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/Parameter.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/ParameterMember.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/ParameterStructs.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/Parameters.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/StructTest.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/ValidityException.java (100%) rename {scijava-struct => struct}/src/test/java/org/scijava/struct/ValidityProblem.java (100%) rename {scijava-swing => swing}/pom.xml (100%) rename {scijava-swing => swing}/src/main/java/module-info.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingChoiceWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingDialog.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingFileWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingInputHarvester.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingMenus.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingMessageWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingNumberWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingPanel.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingPanelFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingTextWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingToggleWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingWidget.java (100%) rename {scijava-swing => swing}/src/main/java/org/scijava/swing/SwingWidgetFactory.java (100%) rename {scijava-swing => swing}/src/test/java/org/scijava/swing/Demos.java (100%) rename {scijava-swing => swing}/src/test/java/org/scijava/swing/ScriptMenuTest.java (100%) rename {scijava-swing => swing}/src/test/java/org/scijava/swing/ScriptWidgetsTest.java (100%) rename {scijava-swing => swing}/src/test/java/org/scijava/swing/Shell.java (100%) rename {scijava-swing => swing}/src/test/java/org/scijava/swing/SwingMenusTest.java (100%) rename {scijava-swing => swing}/src/test/java/org/scijava/swing/SwingWidgetsTest.java (100%) rename {scijava-swing => swing}/src/test/resources/scripts/Process/Filters/Blur_It.groovy (100%) rename {scijava-swing => swing}/src/test/resources/scripts/Say_Hello.groovy (100%) rename {scijava-taglets => taglets}/LICENSE.txt (100%) rename {scijava-taglets => taglets}/README.md (100%) rename {scijava-taglets => taglets}/pom.xml (100%) rename {scijava-taglets => taglets}/src/main/java/org/scijava/taglets/ContainerTaglet.java (100%) rename {scijava-taglets => taglets}/src/main/java/org/scijava/taglets/InputTaglet.java (100%) rename {scijava-taglets => taglets}/src/main/java/org/scijava/taglets/MutableTaglet.java (100%) rename {scijava-taglets => taglets}/src/main/java/org/scijava/taglets/OpDependencyTaglet.java (100%) rename {scijava-taglets => taglets}/src/main/java/org/scijava/taglets/OutputTaglet.java (100%) rename {scijava-taglets => taglets}/src/main/java/org/scijava/taglets/ParamTaglet.java (100%) rename {scijava-testutil => testutil}/LICENSE.txt (100%) rename {scijava-testutil => testutil}/README.md (100%) rename {scijava-testutil => testutil}/pom.xml (100%) rename {scijava-testutil => testutil}/src/main/java/org/scijava/testutil/ArrayIO.java (100%) rename {scijava-testutil => testutil}/src/main/java/org/scijava/testutil/AssertClose.java (100%) rename {scijava-testutil => testutil}/src/main/java/org/scijava/testutil/ExampleTypes.java (100%) rename {scijava-types => types}/LICENSE.txt (100%) rename {scijava-types => types}/NOTICE.txt (100%) rename {scijava-types => types}/README.md (100%) rename {scijava-types => types}/pom.xml (100%) rename {scijava-types => types}/src/main/java/module-info.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/Nil.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/Proxyable.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/extract/DefaultTypeReifier.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/extract/IterableTypeExtractor.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/extract/MapTypeExtractor.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/extract/SubTypeExtractor.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/extract/TypeExtractor.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/extract/TypeReifier.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/AnyTypeMapping.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/FunctionalInterfaces.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/GenericAssignability.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/TypeInferenceException.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/TypeMapping.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/TypeVarAssigns.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/infer/WildcardTypeMapping.java (100%) rename {scijava-types => types}/src/main/java/org/scijava/types/package-info.java (100%) rename {scijava-types => types}/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor (100%) rename {scijava-types => types}/src/test/java/org/scijava/types/ExampleTypes.java (100%) rename {scijava-types => types}/src/test/java/org/scijava/types/NilTest.java (100%) rename {scijava-types => types}/src/test/java/org/scijava/types/extract/DefaultTypeReifierTest.java (100%) rename {scijava-types => types}/src/test/java/org/scijava/types/infer/InferTypeVariablesTest.java (100%) rename {scijava-ui3-test => ui3-test}/pom.xml (100%) rename {scijava-ui3-test => ui3-test}/src/main/java/module-info.java (100%) rename {scijava-ui3-test => ui3-test}/src/main/java/org/scijava/ui3/test/MenuConformance.java (100%) rename {scijava-ui3-test => ui3-test}/src/main/java/org/scijava/ui3/test/SampleCommands.java (100%) rename {scijava-ui3-test => ui3-test}/src/main/java/org/scijava/ui3/test/WidgetConformance.java (100%) rename {scijava-ui3 => ui3}/pom.xml (100%) rename {scijava-ui3 => ui3}/src/main/java/module-info.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/AbstractWidget.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/InputHarvester.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/Widget.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/WidgetFactory.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/WidgetPanel.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/WidgetPanelFactory.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/WidgetPanels.java (100%) rename {scijava-ui3 => ui3}/src/main/java/org/scijava/ui3/Widgets.java (100%) diff --git a/scijava-awt/pom.xml b/awt/pom.xml similarity index 100% rename from scijava-awt/pom.xml rename to awt/pom.xml diff --git a/scijava-awt/src/main/java/module-info.java b/awt/src/main/java/module-info.java similarity index 100% rename from scijava-awt/src/main/java/module-info.java rename to awt/src/main/java/module-info.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtChoice.java b/awt/src/main/java/org/scijava/awt/AwtChoice.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtChoice.java rename to awt/src/main/java/org/scijava/awt/AwtChoice.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtDialog.java b/awt/src/main/java/org/scijava/awt/AwtDialog.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtDialog.java rename to awt/src/main/java/org/scijava/awt/AwtDialog.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtFile.java b/awt/src/main/java/org/scijava/awt/AwtFile.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtFile.java rename to awt/src/main/java/org/scijava/awt/AwtFile.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtInputHarvester.java b/awt/src/main/java/org/scijava/awt/AwtInputHarvester.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtInputHarvester.java rename to awt/src/main/java/org/scijava/awt/AwtInputHarvester.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java b/awt/src/main/java/org/scijava/awt/AwtMenus.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtMenus.java rename to awt/src/main/java/org/scijava/awt/AwtMenus.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtMessage.java b/awt/src/main/java/org/scijava/awt/AwtMessage.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtMessage.java rename to awt/src/main/java/org/scijava/awt/AwtMessage.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtNumber.java b/awt/src/main/java/org/scijava/awt/AwtNumber.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtNumber.java rename to awt/src/main/java/org/scijava/awt/AwtNumber.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtPanel.java b/awt/src/main/java/org/scijava/awt/AwtPanel.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtPanel.java rename to awt/src/main/java/org/scijava/awt/AwtPanel.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtText.java b/awt/src/main/java/org/scijava/awt/AwtText.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtText.java rename to awt/src/main/java/org/scijava/awt/AwtText.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtToggle.java b/awt/src/main/java/org/scijava/awt/AwtToggle.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtToggle.java rename to awt/src/main/java/org/scijava/awt/AwtToggle.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtWidget.java b/awt/src/main/java/org/scijava/awt/AwtWidget.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtWidget.java rename to awt/src/main/java/org/scijava/awt/AwtWidget.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java b/awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java rename to awt/src/main/java/org/scijava/awt/AwtWidgetFactory.java diff --git a/scijava-awt/src/main/java/org/scijava/awt/Edt.java b/awt/src/main/java/org/scijava/awt/Edt.java similarity index 100% rename from scijava-awt/src/main/java/org/scijava/awt/Edt.java rename to awt/src/main/java/org/scijava/awt/Edt.java diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java b/awt/src/test/java/org/scijava/awt/AwtMenusTest.java similarity index 100% rename from scijava-awt/src/test/java/org/scijava/awt/AwtMenusTest.java rename to awt/src/test/java/org/scijava/awt/AwtMenusTest.java diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtShell.java b/awt/src/test/java/org/scijava/awt/AwtShell.java similarity index 100% rename from scijava-awt/src/test/java/org/scijava/awt/AwtShell.java rename to awt/src/test/java/org/scijava/awt/AwtShell.java diff --git a/scijava-awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java b/awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java similarity index 100% rename from scijava-awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java rename to awt/src/test/java/org/scijava/awt/AwtWidgetsTest.java diff --git a/scijava-awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy similarity index 100% rename from scijava-awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy rename to awt/src/test/resources/scripts/Process/Filters/Blur_It.groovy diff --git a/scijava-awt/src/test/resources/scripts/Say_Hello.groovy b/awt/src/test/resources/scripts/Say_Hello.groovy similarity index 100% rename from scijava-awt/src/test/resources/scripts/Say_Hello.groovy rename to awt/src/test/resources/scripts/Say_Hello.groovy diff --git a/scijava-cli/pom.xml b/cli/pom.xml similarity index 100% rename from scijava-cli/pom.xml rename to cli/pom.xml diff --git a/scijava-cli/src/main/java/module-info.java b/cli/src/main/java/module-info.java similarity index 100% rename from scijava-cli/src/main/java/module-info.java rename to cli/src/main/java/module-info.java diff --git a/scijava-cli/src/main/java/org/scijava/cli/Arguments.java b/cli/src/main/java/org/scijava/cli/Arguments.java similarity index 100% rename from scijava-cli/src/main/java/org/scijava/cli/Arguments.java rename to cli/src/main/java/org/scijava/cli/Arguments.java diff --git a/scijava-cli/src/main/java/org/scijava/cli/Cli.java b/cli/src/main/java/org/scijava/cli/Cli.java similarity index 100% rename from scijava-cli/src/main/java/org/scijava/cli/Cli.java rename to cli/src/main/java/org/scijava/cli/Cli.java diff --git a/scijava-cli/src/main/java/org/scijava/cli/CommandLookup.java b/cli/src/main/java/org/scijava/cli/CommandLookup.java similarity index 100% rename from scijava-cli/src/main/java/org/scijava/cli/CommandLookup.java rename to cli/src/main/java/org/scijava/cli/CommandLookup.java diff --git a/scijava-cli/src/test/java/org/scijava/cli/CliCommands.java b/cli/src/test/java/org/scijava/cli/CliCommands.java similarity index 100% rename from scijava-cli/src/test/java/org/scijava/cli/CliCommands.java rename to cli/src/test/java/org/scijava/cli/CliCommands.java diff --git a/scijava-cli/src/test/java/org/scijava/cli/CliTest.java b/cli/src/test/java/org/scijava/cli/CliTest.java similarity index 100% rename from scijava-cli/src/test/java/org/scijava/cli/CliTest.java rename to cli/src/test/java/org/scijava/cli/CliTest.java 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/scijava-collections/README.md b/collections/README.md similarity index 100% rename from scijava-collections/README.md rename to collections/README.md 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/scijava-command3/pom.xml b/command3/pom.xml similarity index 100% rename from scijava-command3/pom.xml rename to command3/pom.xml diff --git a/scijava-command3/src/main/java/module-info.java b/command3/src/main/java/module-info.java similarity index 100% rename from scijava-command3/src/main/java/module-info.java rename to command3/src/main/java/module-info.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/Accelerator.java b/command3/src/main/java/org/scijava/command3/Accelerator.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/Accelerator.java rename to command3/src/main/java/org/scijava/command3/Accelerator.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/ClassCommandInfo.java b/command3/src/main/java/org/scijava/command3/ClassCommandInfo.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/ClassCommandInfo.java rename to command3/src/main/java/org/scijava/command3/ClassCommandInfo.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/Command.java b/command3/src/main/java/org/scijava/command3/Command.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/Command.java rename to command3/src/main/java/org/scijava/command3/Command.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/CommandInfo.java b/command3/src/main/java/org/scijava/command3/CommandInfo.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/CommandInfo.java rename to command3/src/main/java/org/scijava/command3/CommandInfo.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/Commands.java b/command3/src/main/java/org/scijava/command3/Commands.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/Commands.java rename to command3/src/main/java/org/scijava/command3/Commands.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/Menu.java b/command3/src/main/java/org/scijava/command3/Menu.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/Menu.java rename to command3/src/main/java/org/scijava/command3/Menu.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/MenuCreator.java b/command3/src/main/java/org/scijava/command3/MenuCreator.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/MenuCreator.java rename to command3/src/main/java/org/scijava/command3/MenuCreator.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/MenuTree.java b/command3/src/main/java/org/scijava/command3/MenuTree.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/MenuTree.java rename to command3/src/main/java/org/scijava/command3/MenuTree.java diff --git a/scijava-command3/src/main/java/org/scijava/command3/Menus.java b/command3/src/main/java/org/scijava/command3/Menus.java similarity index 100% rename from scijava-command3/src/main/java/org/scijava/command3/Menus.java rename to command3/src/main/java/org/scijava/command3/Menus.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/CommandsTest.java b/command3/src/test/java/org/scijava/command3/CommandsTest.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/CommandsTest.java rename to command3/src/test/java/org/scijava/command3/CommandsTest.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/CountBeans.java b/command3/src/test/java/org/scijava/command3/CountBeans.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/CountBeans.java rename to command3/src/test/java/org/scijava/command3/CountBeans.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/DeepCommand.java b/command3/src/test/java/org/scijava/command3/DeepCommand.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/DeepCommand.java rename to command3/src/test/java/org/scijava/command3/DeepCommand.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/Headless.java b/command3/src/test/java/org/scijava/command3/Headless.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/Headless.java rename to command3/src/test/java/org/scijava/command3/Headless.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/HiddenCommand.java b/command3/src/test/java/org/scijava/command3/HiddenCommand.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/HiddenCommand.java rename to command3/src/test/java/org/scijava/command3/HiddenCommand.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/MenuTreeTest.java b/command3/src/test/java/org/scijava/command3/MenuTreeTest.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/MenuTreeTest.java rename to command3/src/test/java/org/scijava/command3/MenuTreeTest.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/NeverRun.java b/command3/src/test/java/org/scijava/command3/NeverRun.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/NeverRun.java rename to command3/src/test/java/org/scijava/command3/NeverRun.java diff --git a/scijava-command3/src/test/java/org/scijava/command3/SayHello.java b/command3/src/test/java/org/scijava/command3/SayHello.java similarity index 100% rename from scijava-command3/src/test/java/org/scijava/command3/SayHello.java rename to command3/src/test/java/org/scijava/command3/SayHello.java 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/scijava-common3/src/main/java/org/scijava/common3/Digests.java b/common3/src/main/java/org/scijava/common3/Digests.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Digests.java rename to common3/src/main/java/org/scijava/common3/Digests.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/FilePaths.java b/common3/src/main/java/org/scijava/common3/FilePaths.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/FilePaths.java rename to common3/src/main/java/org/scijava/common3/FilePaths.java 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/scijava-common3/src/main/java/org/scijava/common3/Numbers.java b/common3/src/main/java/org/scijava/common3/Numbers.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Numbers.java rename to common3/src/main/java/org/scijava/common3/Numbers.java 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/scijava-common3/src/main/java/org/scijava/common3/Processes.java b/common3/src/main/java/org/scijava/common3/Processes.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Processes.java rename to common3/src/main/java/org/scijava/common3/Processes.java diff --git a/scijava-common3/src/main/java/org/scijava/common3/Strings.java b/common3/src/main/java/org/scijava/common3/Strings.java similarity index 100% rename from scijava-common3/src/main/java/org/scijava/common3/Strings.java rename to common3/src/main/java/org/scijava/common3/Strings.java 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/scijava-common3/src/test/java/org/scijava/common3/DigestsTest.java b/common3/src/test/java/org/scijava/common3/DigestsTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/DigestsTest.java rename to common3/src/test/java/org/scijava/common3/DigestsTest.java diff --git a/scijava-common3/src/test/java/org/scijava/common3/FilePathsTest.java b/common3/src/test/java/org/scijava/common3/FilePathsTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/FilePathsTest.java rename to common3/src/test/java/org/scijava/common3/FilePathsTest.java diff --git a/scijava-common3/src/test/java/org/scijava/common3/NumbersTest.java b/common3/src/test/java/org/scijava/common3/NumbersTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/NumbersTest.java rename to common3/src/test/java/org/scijava/common3/NumbersTest.java diff --git a/scijava-common3/src/test/java/org/scijava/common3/ProcessesTest.java b/common3/src/test/java/org/scijava/common3/ProcessesTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/ProcessesTest.java rename to common3/src/test/java/org/scijava/common3/ProcessesTest.java diff --git a/scijava-common3/src/test/java/org/scijava/common3/StringsTest.java b/common3/src/test/java/org/scijava/common3/StringsTest.java similarity index 100% rename from scijava-common3/src/test/java/org/scijava/common3/StringsTest.java rename to common3/src/test/java/org/scijava/common3/StringsTest.java 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/scijava-compat/pom.xml b/compat/pom.xml similarity index 100% rename from scijava-compat/pom.xml rename to compat/pom.xml diff --git a/scijava-compat/src/main/java/module-info.java b/compat/src/main/java/module-info.java similarity index 100% rename from scijava-compat/src/main/java/module-info.java rename to compat/src/main/java/module-info.java diff --git a/scijava-compat/src/main/java/org/scijava/compat/Legacy.java b/compat/src/main/java/org/scijava/compat/Legacy.java similarity index 100% rename from scijava-compat/src/main/java/org/scijava/compat/Legacy.java rename to compat/src/main/java/org/scijava/compat/Legacy.java diff --git a/scijava-compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java b/compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java similarity index 100% rename from scijava-compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java rename to compat/src/main/java/org/scijava/compat/LegacyCommandInfo.java diff --git a/scijava-compat/src/main/java/org/scijava/compat/LegacyContext.java b/compat/src/main/java/org/scijava/compat/LegacyContext.java similarity index 100% rename from scijava-compat/src/main/java/org/scijava/compat/LegacyContext.java rename to compat/src/main/java/org/scijava/compat/LegacyContext.java diff --git a/scijava-compat/src/main/java/org/scijava/compat/LegacyMember.java b/compat/src/main/java/org/scijava/compat/LegacyMember.java similarity index 100% rename from scijava-compat/src/main/java/org/scijava/compat/LegacyMember.java rename to compat/src/main/java/org/scijava/compat/LegacyMember.java diff --git a/scijava-compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java b/compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java similarity index 100% rename from scijava-compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java rename to compat/src/test/java/org/scijava/compat/LegacyBridgeTest.java diff --git a/scijava-compat/src/test/java/org/scijava/compat/LegacyCommands.java b/compat/src/test/java/org/scijava/compat/LegacyCommands.java similarity index 100% rename from scijava-compat/src/test/java/org/scijava/compat/LegacyCommands.java rename to compat/src/test/java/org/scijava/compat/LegacyCommands.java diff --git a/scijava-compat/src/test/java/org/scijava/compat/LegacyDialogTest.java b/compat/src/test/java/org/scijava/compat/LegacyDialogTest.java similarity index 100% rename from scijava-compat/src/test/java/org/scijava/compat/LegacyDialogTest.java rename to compat/src/test/java/org/scijava/compat/LegacyDialogTest.java 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 100% rename from scijava-concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java rename to concurrent/src/main/java/org/scijava/concurrent/TaskExecutors.java diff --git a/scijava-concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java b/concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java similarity index 100% rename from scijava-concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java rename to concurrent/src/test/java/org/scijava/concurrent/CancellationTest.java 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/scijava-context-test/pom.xml b/context-test/pom.xml similarity index 100% rename from scijava-context-test/pom.xml rename to context-test/pom.xml diff --git a/scijava-context-test/src/main/java/module-info.java b/context-test/src/main/java/module-info.java similarity index 100% rename from scijava-context-test/src/main/java/module-info.java rename to context-test/src/main/java/module-info.java diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/GreeterService.java b/context-test/src/main/java/org/scijava/context/test/GreeterService.java similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/GreeterService.java rename to context-test/src/main/java/org/scijava/context/test/GreeterService.java diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/RecordingService.java b/context-test/src/main/java/org/scijava/context/test/RecordingService.java similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/RecordingService.java rename to context-test/src/main/java/org/scijava/context/test/RecordingService.java diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java b/context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java rename to context-test/src/main/java/org/scijava/context/test/ShoutRecorded.java diff --git a/scijava-context-test/src/main/java/org/scijava/context/test/Shouter.java b/context-test/src/main/java/org/scijava/context/test/Shouter.java similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/Shouter.java rename to context-test/src/main/java/org/scijava/context/test/Shouter.java diff --git a/scijava-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 similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java rename to context-test/src/main/java/org/scijava/context/test/impl/DefaultRecordingService.java diff --git a/scijava-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 similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java rename to context-test/src/main/java/org/scijava/context/test/impl/HiddenGreeter.java diff --git a/scijava-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 similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java rename to context-test/src/main/java/org/scijava/context/test/impl/InjectedShouter.java diff --git a/scijava-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 similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java rename to context-test/src/main/java/org/scijava/context/test/impl/LoudShouter.java diff --git a/scijava-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 similarity index 100% rename from scijava-context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java rename to context-test/src/main/java/org/scijava/context/test/impl/QuietShouter.java diff --git a/scijava-context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java b/context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java similarity index 100% rename from scijava-context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java rename to context-test/src/test/java/org/scijava/context/test/EventHandlerTest.java diff --git a/scijava-context-test/src/test/java/org/scijava/context/test/InjectionTest.java b/context-test/src/test/java/org/scijava/context/test/InjectionTest.java similarity index 100% rename from scijava-context-test/src/test/java/org/scijava/context/test/InjectionTest.java rename to context-test/src/test/java/org/scijava/context/test/InjectionTest.java diff --git a/scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java b/context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java similarity index 100% rename from scijava-context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java rename to context-test/src/test/java/org/scijava/context/test/PluginDiscoveryTest.java diff --git a/scijava-context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java b/context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java similarity index 100% rename from scijava-context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java rename to context-test/src/test/java/org/scijava/context/test/ServiceDiscoveryTest.java diff --git a/scijava-context/.factorypath b/context/.factorypath similarity index 100% rename from scijava-context/.factorypath rename to context/.factorypath diff --git a/scijava-context/pom.xml b/context/pom.xml similarity index 100% rename from scijava-context/pom.xml rename to context/pom.xml diff --git a/scijava-context/src/main/java/module-info.java b/context/src/main/java/module-info.java similarity index 100% rename from scijava-context/src/main/java/module-info.java rename to context/src/main/java/module-info.java diff --git a/scijava-context/src/main/java/org/scijava/context/Access.java b/context/src/main/java/org/scijava/context/Access.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/Access.java rename to context/src/main/java/org/scijava/context/Access.java diff --git a/scijava-context/src/main/java/org/scijava/context/Attr.java b/context/src/main/java/org/scijava/context/Attr.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/Attr.java rename to context/src/main/java/org/scijava/context/Attr.java diff --git a/scijava-context/src/main/java/org/scijava/context/Context.java b/context/src/main/java/org/scijava/context/Context.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/Context.java rename to context/src/main/java/org/scijava/context/Context.java diff --git a/scijava-context/src/main/java/org/scijava/context/Dependency.java b/context/src/main/java/org/scijava/context/Dependency.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/Dependency.java rename to context/src/main/java/org/scijava/context/Dependency.java diff --git a/scijava-context/src/main/java/org/scijava/context/EventHandler.java b/context/src/main/java/org/scijava/context/EventHandler.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/EventHandler.java rename to context/src/main/java/org/scijava/context/EventHandler.java diff --git a/scijava-context/src/main/java/org/scijava/context/NoSuchServiceException.java b/context/src/main/java/org/scijava/context/NoSuchServiceException.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/NoSuchServiceException.java rename to context/src/main/java/org/scijava/context/NoSuchServiceException.java diff --git a/scijava-context/src/main/java/org/scijava/context/Plugin.java b/context/src/main/java/org/scijava/context/Plugin.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/Plugin.java rename to context/src/main/java/org/scijava/context/Plugin.java diff --git a/scijava-context/src/main/java/org/scijava/context/Service.java b/context/src/main/java/org/scijava/context/Service.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/Service.java rename to context/src/main/java/org/scijava/context/Service.java diff --git a/scijava-context/src/main/java/org/scijava/context/ServiceException.java b/context/src/main/java/org/scijava/context/ServiceException.java similarity index 100% rename from scijava-context/src/main/java/org/scijava/context/ServiceException.java rename to context/src/main/java/org/scijava/context/ServiceException.java diff --git a/scijava-context/src/test/java/org/scijava/context/ContextTest.java b/context/src/test/java/org/scijava/context/ContextTest.java similarity index 100% rename from scijava-context/src/test/java/org/scijava/context/ContextTest.java rename to context/src/test/java/org/scijava/context/ContextTest.java diff --git a/scijava-convert3/pom.xml b/convert3/pom.xml similarity index 100% rename from scijava-convert3/pom.xml rename to convert3/pom.xml diff --git a/scijava-convert3/src/main/java/module-info.java b/convert3/src/main/java/module-info.java similarity index 100% rename from scijava-convert3/src/main/java/module-info.java rename to convert3/src/main/java/module-info.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/ArrayConverter.java b/convert3/src/main/java/org/scijava/convert3/ArrayConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/ArrayConverter.java rename to convert3/src/main/java/org/scijava/convert3/ArrayConverter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java b/convert3/src/main/java/org/scijava/convert3/CastConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/CastConverter.java rename to convert3/src/main/java/org/scijava/convert3/CastConverter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/CollectionConverter.java b/convert3/src/main/java/org/scijava/convert3/CollectionConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/CollectionConverter.java rename to convert3/src/main/java/org/scijava/convert3/CollectionConverter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java b/convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java rename to convert3/src/main/java/org/scijava/convert3/ConstructorConverter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/ConversionException.java b/convert3/src/main/java/org/scijava/convert3/ConversionException.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/ConversionException.java rename to convert3/src/main/java/org/scijava/convert3/ConversionException.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/Converter.java b/convert3/src/main/java/org/scijava/convert3/Converter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/Converter.java rename to convert3/src/main/java/org/scijava/convert3/Converter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/Converters.java b/convert3/src/main/java/org/scijava/convert3/Converters.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/Converters.java rename to convert3/src/main/java/org/scijava/convert3/Converters.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/NumberConverter.java b/convert3/src/main/java/org/scijava/convert3/NumberConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/NumberConverter.java rename to convert3/src/main/java/org/scijava/convert3/NumberConverter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/PathConverters.java b/convert3/src/main/java/org/scijava/convert3/PathConverters.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/PathConverters.java rename to convert3/src/main/java/org/scijava/convert3/PathConverters.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/StringConverter.java b/convert3/src/main/java/org/scijava/convert3/StringConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/StringConverter.java rename to convert3/src/main/java/org/scijava/convert3/StringConverter.java diff --git a/scijava-convert3/src/main/java/org/scijava/convert3/ToStringConverter.java b/convert3/src/main/java/org/scijava/convert3/ToStringConverter.java similarity index 100% rename from scijava-convert3/src/main/java/org/scijava/convert3/ToStringConverter.java rename to convert3/src/main/java/org/scijava/convert3/ToStringConverter.java diff --git a/scijava-convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter b/convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter similarity index 100% rename from scijava-convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter rename to convert3/src/main/resources/META-INF/services/org.scijava.convert3.Converter diff --git a/scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java b/convert3/src/test/java/org/scijava/convert3/ConvertersTest.java similarity index 100% rename from scijava-convert3/src/test/java/org/scijava/convert3/ConvertersTest.java rename to convert3/src/test/java/org/scijava/convert3/ConvertersTest.java 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/scijava-discovery-test/README.md b/discovery-test/README.md similarity index 100% rename from scijava-discovery-test/README.md rename to discovery-test/README.md diff --git a/scijava-discovery-test/pom.xml b/discovery-test/pom.xml similarity index 100% rename from scijava-discovery-test/pom.xml rename to discovery-test/pom.xml 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 100% 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 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/scijava-discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java b/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java similarity index 100% rename from scijava-discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java rename to discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java 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 100% 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 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/scijava-discovery/README.md b/discovery/README.md similarity index 100% rename from scijava-discovery/README.md rename to discovery/README.md diff --git a/scijava-discovery/pom.xml b/discovery/pom.xml similarity index 100% rename from scijava-discovery/pom.xml rename to discovery/pom.xml diff --git a/scijava-discovery/src/main/java/module-info.java b/discovery/src/main/java/module-info.java similarity index 100% rename from scijava-discovery/src/main/java/module-info.java rename to discovery/src/main/java/module-info.java diff --git a/scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java b/discovery/src/main/java/org/scijava/discovery/Discoverer.java similarity index 100% rename from scijava-discovery/src/main/java/org/scijava/discovery/Discoverer.java rename to discovery/src/main/java/org/scijava/discovery/Discoverer.java diff --git a/scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java b/discovery/src/main/java/org/scijava/discovery/Discovery.java similarity index 100% rename from scijava-discovery/src/main/java/org/scijava/discovery/Discovery.java rename to discovery/src/main/java/org/scijava/discovery/Discovery.java 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 100% rename from scijava-discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java rename to discovery/src/main/java/org/scijava/discovery/ManualDiscoverer.java diff --git a/scijava-events/pom.xml b/events/pom.xml similarity index 100% rename from scijava-events/pom.xml rename to events/pom.xml diff --git a/scijava-events/src/main/java/module-info.java b/events/src/main/java/module-info.java similarity index 100% rename from scijava-events/src/main/java/module-info.java rename to events/src/main/java/module-info.java diff --git a/scijava-events/src/main/java/org/scijava/events/Consumable.java b/events/src/main/java/org/scijava/events/Consumable.java similarity index 100% rename from scijava-events/src/main/java/org/scijava/events/Consumable.java rename to events/src/main/java/org/scijava/events/Consumable.java diff --git a/scijava-events/src/main/java/org/scijava/events/DefaultEventBus.java b/events/src/main/java/org/scijava/events/DefaultEventBus.java similarity index 100% rename from scijava-events/src/main/java/org/scijava/events/DefaultEventBus.java rename to events/src/main/java/org/scijava/events/DefaultEventBus.java diff --git a/scijava-events/src/main/java/org/scijava/events/EventBus.java b/events/src/main/java/org/scijava/events/EventBus.java similarity index 100% rename from scijava-events/src/main/java/org/scijava/events/EventBus.java rename to events/src/main/java/org/scijava/events/EventBus.java diff --git a/scijava-events/src/main/java/org/scijava/events/EventDeliveryException.java b/events/src/main/java/org/scijava/events/EventDeliveryException.java similarity index 100% rename from scijava-events/src/main/java/org/scijava/events/EventDeliveryException.java rename to events/src/main/java/org/scijava/events/EventDeliveryException.java diff --git a/scijava-events/src/main/java/org/scijava/events/Subscription.java b/events/src/main/java/org/scijava/events/Subscription.java similarity index 100% rename from scijava-events/src/main/java/org/scijava/events/Subscription.java rename to events/src/main/java/org/scijava/events/Subscription.java diff --git a/scijava-events/src/test/java/org/scijava/events/EventBusTest.java b/events/src/test/java/org/scijava/events/EventBusTest.java similarity index 100% rename from scijava-events/src/test/java/org/scijava/events/EventBusTest.java rename to events/src/test/java/org/scijava/events/EventBusTest.java diff --git a/scijava-execute/pom.xml b/execute/pom.xml similarity index 100% rename from scijava-execute/pom.xml rename to execute/pom.xml diff --git a/scijava-execute/src/main/java/module-info.java b/execute/src/main/java/module-info.java similarity index 100% rename from scijava-execute/src/main/java/module-info.java rename to execute/src/main/java/module-info.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Behavior.java b/execute/src/main/java/org/scijava/execute/Behavior.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Behavior.java rename to execute/src/main/java/org/scijava/execute/Behavior.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/BehaviorException.java b/execute/src/main/java/org/scijava/execute/BehaviorException.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/BehaviorException.java rename to execute/src/main/java/org/scijava/execute/BehaviorException.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executable.java b/execute/src/main/java/org/scijava/execute/Executable.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Executable.java rename to execute/src/main/java/org/scijava/execute/Executable.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java b/execute/src/main/java/org/scijava/execute/ExecutableInstance.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/ExecutableInstance.java rename to execute/src/main/java/org/scijava/execute/ExecutableInstance.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Executables.java b/execute/src/main/java/org/scijava/execute/Executables.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Executables.java rename to execute/src/main/java/org/scijava/execute/Executables.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Execution.java b/execute/src/main/java/org/scijava/execute/Execution.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Execution.java rename to execute/src/main/java/org/scijava/execute/Execution.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/ExecutionResult.java b/execute/src/main/java/org/scijava/execute/ExecutionResult.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/ExecutionResult.java rename to execute/src/main/java/org/scijava/execute/ExecutionResult.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/execute/src/main/java/org/scijava/execute/FieldParameterMember.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/FieldParameterMember.java rename to execute/src/main/java/org/scijava/execute/FieldParameterMember.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java b/execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java rename to execute/src/main/java/org/scijava/execute/FieldParameterMemberParser.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java b/execute/src/main/java/org/scijava/execute/JavaExecutable.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/JavaExecutable.java rename to execute/src/main/java/org/scijava/execute/JavaExecutable.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameter.java b/execute/src/main/java/org/scijava/execute/Parameter.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Parameter.java rename to execute/src/main/java/org/scijava/execute/Parameter.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java b/execute/src/main/java/org/scijava/execute/ParameterMember.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/ParameterMember.java rename to execute/src/main/java/org/scijava/execute/ParameterMember.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Parameters.java b/execute/src/main/java/org/scijava/execute/Parameters.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Parameters.java rename to execute/src/main/java/org/scijava/execute/Parameters.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Postprocessor.java b/execute/src/main/java/org/scijava/execute/Postprocessor.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Postprocessor.java rename to execute/src/main/java/org/scijava/execute/Postprocessor.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Preprocessor.java b/execute/src/main/java/org/scijava/execute/Preprocessor.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Preprocessor.java rename to execute/src/main/java/org/scijava/execute/Preprocessor.java diff --git a/scijava-execute/src/main/java/org/scijava/execute/Runner.java b/execute/src/main/java/org/scijava/execute/Runner.java similarity index 100% rename from scijava-execute/src/main/java/org/scijava/execute/Runner.java rename to execute/src/main/java/org/scijava/execute/Runner.java diff --git a/scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java b/execute/src/test/java/org/scijava/execute/ExecutablesTest.java similarity index 100% rename from scijava-execute/src/test/java/org/scijava/execute/ExecutablesTest.java rename to execute/src/test/java/org/scijava/execute/ExecutablesTest.java diff --git a/scijava-execute/src/test/java/org/scijava/execute/RunnerTest.java b/execute/src/test/java/org/scijava/execute/RunnerTest.java similarity index 100% rename from scijava-execute/src/test/java/org/scijava/execute/RunnerTest.java rename to execute/src/test/java/org/scijava/execute/RunnerTest.java diff --git a/scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java b/execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java similarity index 100% rename from scijava-execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java rename to execute/src/test/java/org/scijava/execute/ScriptLikeExecutableTest.java 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/scijava-harvest/pom.xml b/harvest/pom.xml similarity index 100% rename from scijava-harvest/pom.xml rename to harvest/pom.xml diff --git a/scijava-harvest/src/main/java/module-info.java b/harvest/src/main/java/module-info.java similarity index 100% rename from scijava-harvest/src/main/java/module-info.java rename to harvest/src/main/java/module-info.java diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/Group.java b/harvest/src/main/java/org/scijava/harvest/Group.java similarity index 100% rename from scijava-harvest/src/main/java/org/scijava/harvest/Group.java rename to harvest/src/main/java/org/scijava/harvest/Group.java diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/Groups.java b/harvest/src/main/java/org/scijava/harvest/Groups.java similarity index 100% rename from scijava-harvest/src/main/java/org/scijava/harvest/Groups.java rename to harvest/src/main/java/org/scijava/harvest/Groups.java diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ObservableStruct.java b/harvest/src/main/java/org/scijava/harvest/ObservableStruct.java similarity index 100% rename from scijava-harvest/src/main/java/org/scijava/harvest/ObservableStruct.java rename to harvest/src/main/java/org/scijava/harvest/ObservableStruct.java diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterModel.java b/harvest/src/main/java/org/scijava/harvest/ParameterModel.java similarity index 100% rename from scijava-harvest/src/main/java/org/scijava/harvest/ParameterModel.java rename to harvest/src/main/java/org/scijava/harvest/ParameterModel.java diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java b/harvest/src/main/java/org/scijava/harvest/ParameterNode.java similarity index 100% rename from scijava-harvest/src/main/java/org/scijava/harvest/ParameterNode.java rename to harvest/src/main/java/org/scijava/harvest/ParameterNode.java diff --git a/scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java b/harvest/src/main/java/org/scijava/harvest/ParameterTree.java similarity index 100% rename from scijava-harvest/src/main/java/org/scijava/harvest/ParameterTree.java rename to harvest/src/main/java/org/scijava/harvest/ParameterTree.java diff --git a/scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java b/harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java similarity index 100% rename from scijava-harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java rename to harvest/src/test/java/org/scijava/harvest/ParameterModelTest.java diff --git a/scijava-harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java b/harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java similarity index 100% rename from scijava-harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java rename to harvest/src/test/java/org/scijava/harvest/ParameterTreeTest.java diff --git a/scijava-imagej-compat/pom.xml b/imagej-compat/pom.xml similarity index 100% rename from scijava-imagej-compat/pom.xml rename to imagej-compat/pom.xml diff --git a/scijava-imagej-compat/src/main/java/module-info.java b/imagej-compat/src/main/java/module-info.java similarity index 100% rename from scijava-imagej-compat/src/main/java/module-info.java rename to imagej-compat/src/main/java/module-info.java diff --git a/scijava-imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java b/imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java similarity index 100% rename from scijava-imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java rename to imagej-compat/src/main/java/org/scijava/imagej/compat/DatasetTypeExtractor.java diff --git a/scijava-imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java b/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java similarity index 100% rename from scijava-imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java rename to imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java diff --git a/scijava-index/pom.xml b/index/pom.xml similarity index 100% rename from scijava-index/pom.xml rename to index/pom.xml diff --git a/scijava-index/src/main/java/module-info.java b/index/src/main/java/module-info.java similarity index 100% rename from scijava-index/src/main/java/module-info.java rename to index/src/main/java/module-info.java diff --git a/scijava-index/src/main/java/org/scijava/index/AbstractIndexWriter.java b/index/src/main/java/org/scijava/index/AbstractIndexWriter.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/AbstractIndexWriter.java rename to index/src/main/java/org/scijava/index/AbstractIndexWriter.java diff --git a/scijava-index/src/main/java/org/scijava/index/AnnotationProcessor.java b/index/src/main/java/org/scijava/index/AnnotationProcessor.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/AnnotationProcessor.java rename to index/src/main/java/org/scijava/index/AnnotationProcessor.java diff --git a/scijava-index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java b/index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java rename to index/src/main/java/org/scijava/index/ByteCodeAnalyzer.java diff --git a/scijava-index/src/main/java/org/scijava/index/DirectoryIndexer.java b/index/src/main/java/org/scijava/index/DirectoryIndexer.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/DirectoryIndexer.java rename to index/src/main/java/org/scijava/index/DirectoryIndexer.java diff --git a/scijava-index/src/main/java/org/scijava/index/Index.java b/index/src/main/java/org/scijava/index/Index.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/Index.java rename to index/src/main/java/org/scijava/index/Index.java diff --git a/scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java b/index/src/main/java/org/scijava/index/IndexDiscoverer.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/IndexDiscoverer.java rename to index/src/main/java/org/scijava/index/IndexDiscoverer.java diff --git a/scijava-index/src/main/java/org/scijava/index/IndexItem.java b/index/src/main/java/org/scijava/index/IndexItem.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/IndexItem.java rename to index/src/main/java/org/scijava/index/IndexItem.java diff --git a/scijava-index/src/main/java/org/scijava/index/IndexReader.java b/index/src/main/java/org/scijava/index/IndexReader.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/IndexReader.java rename to index/src/main/java/org/scijava/index/IndexReader.java diff --git a/scijava-index/src/main/java/org/scijava/index/Indexable.java b/index/src/main/java/org/scijava/index/Indexable.java similarity index 100% rename from scijava-index/src/main/java/org/scijava/index/Indexable.java rename to index/src/main/java/org/scijava/index/Indexable.java diff --git a/scijava-index/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/index/src/main/resources/META-INF/services/javax.annotation.processing.Processor similarity index 100% rename from scijava-index/src/main/resources/META-INF/services/javax.annotation.processing.Processor rename to index/src/main/resources/META-INF/services/javax.annotation.processing.Processor diff --git a/scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java b/index/src/test/java/org/scijava/index/AnnotationProcessorTest.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/AnnotationProcessorTest.java rename to index/src/test/java/org/scijava/index/AnnotationProcessorTest.java diff --git a/scijava-index/src/test/java/org/scijava/index/Circle.java b/index/src/test/java/org/scijava/index/Circle.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/Circle.java rename to index/src/test/java/org/scijava/index/Circle.java diff --git a/scijava-index/src/test/java/org/scijava/index/Color.java b/index/src/test/java/org/scijava/index/Color.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/Color.java rename to index/src/test/java/org/scijava/index/Color.java diff --git a/scijava-index/src/test/java/org/scijava/index/IndexDiscovererTest.java b/index/src/test/java/org/scijava/index/IndexDiscovererTest.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/IndexDiscovererTest.java rename to index/src/test/java/org/scijava/index/IndexDiscovererTest.java diff --git a/scijava-index/src/test/java/org/scijava/index/Red.java b/index/src/test/java/org/scijava/index/Red.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/Red.java rename to index/src/test/java/org/scijava/index/Red.java diff --git a/scijava-index/src/test/java/org/scijava/index/Shape.java b/index/src/test/java/org/scijava/index/Shape.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/Shape.java rename to index/src/test/java/org/scijava/index/Shape.java diff --git a/scijava-index/src/test/java/org/scijava/index/Square.java b/index/src/test/java/org/scijava/index/Square.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/Square.java rename to index/src/test/java/org/scijava/index/Square.java diff --git a/scijava-index/src/test/java/org/scijava/index/Widget.java b/index/src/test/java/org/scijava/index/Widget.java similarity index 100% rename from scijava-index/src/test/java/org/scijava/index/Widget.java rename to index/src/test/java/org/scijava/index/Widget.java diff --git a/scijava-io3/pom.xml b/io3/pom.xml similarity index 100% rename from scijava-io3/pom.xml rename to io3/pom.xml diff --git a/scijava-io3/src/main/java/module-info.java b/io3/src/main/java/module-info.java similarity index 100% rename from scijava-io3/src/main/java/module-info.java rename to io3/src/main/java/module-info.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java b/io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java rename to io3/src/main/java/org/scijava/io3/ByteArrayByteBank.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/ByteBank.java b/io3/src/main/java/org/scijava/io3/ByteBank.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/ByteBank.java rename to io3/src/main/java/org/scijava/io3/ByteBank.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java b/io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java rename to io3/src/main/java/org/scijava/io3/ByteBufferByteBank.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/Bytes.java b/io3/src/main/java/org/scijava/io3/Bytes.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/Bytes.java rename to io3/src/main/java/org/scijava/io3/Bytes.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java rename to io3/src/main/java/org/scijava/io3/handle/AbstractDataHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java rename to io3/src/main/java/org/scijava/io3/handle/AbstractHigherOrderHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java rename to io3/src/main/java/org/scijava/io3/handle/AbstractSeekableStreamHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java rename to io3/src/main/java/org/scijava/io3/handle/AbstractStreamHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/BytesHandle.java b/io3/src/main/java/org/scijava/io3/handle/BytesHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/BytesHandle.java rename to io3/src/main/java/org/scijava/io3/handle/BytesHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandle.java b/io3/src/main/java/org/scijava/io3/handle/DataHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/DataHandle.java rename to io3/src/main/java/org/scijava/io3/handle/DataHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java b/io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java rename to io3/src/main/java/org/scijava/io3/handle/DataHandleInputStream.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java b/io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java rename to io3/src/main/java/org/scijava/io3/handle/DataHandleOutputStream.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java b/io3/src/main/java/org/scijava/io3/handle/DataHandles.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/DataHandles.java rename to io3/src/main/java/org/scijava/io3/handle/DataHandles.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/DummyHandle.java b/io3/src/main/java/org/scijava/io3/handle/DummyHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/DummyHandle.java rename to io3/src/main/java/org/scijava/io3/handle/DummyHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/FileHandle.java b/io3/src/main/java/org/scijava/io3/handle/FileHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/FileHandle.java rename to io3/src/main/java/org/scijava/io3/handle/FileHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java b/io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java rename to io3/src/main/java/org/scijava/io3/handle/ReadBufferDataHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java rename to io3/src/main/java/org/scijava/io3/handle/ResettableStreamHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java rename to io3/src/main/java/org/scijava/io3/handle/SeekableStreamHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/StreamHandle.java b/io3/src/main/java/org/scijava/io3/handle/StreamHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/StreamHandle.java rename to io3/src/main/java/org/scijava/io3/handle/StreamHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java b/io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java rename to io3/src/main/java/org/scijava/io3/handle/WriteBufferDataHandle.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocation.java b/io3/src/main/java/org/scijava/io3/location/AbstractLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocation.java rename to io3/src/main/java/org/scijava/io3/location/AbstractLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java b/io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java rename to io3/src/main/java/org/scijava/io3/location/AbstractLocationResolver.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java b/io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java rename to io3/src/main/java/org/scijava/io3/location/AbstractRemoteLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java b/io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java rename to io3/src/main/java/org/scijava/io3/location/BrowsableLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/BytesLocation.java b/io3/src/main/java/org/scijava/io3/location/BytesLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/BytesLocation.java rename to io3/src/main/java/org/scijava/io3/location/BytesLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/DummyLocation.java b/io3/src/main/java/org/scijava/io3/location/DummyLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/DummyLocation.java rename to io3/src/main/java/org/scijava/io3/location/DummyLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/FileLocation.java b/io3/src/main/java/org/scijava/io3/location/FileLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/FileLocation.java rename to io3/src/main/java/org/scijava/io3/location/FileLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java b/io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java rename to io3/src/main/java/org/scijava/io3/location/FileLocationResolver.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/Location.java b/io3/src/main/java/org/scijava/io3/location/Location.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/Location.java rename to io3/src/main/java/org/scijava/io3/location/Location.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/LocationResolver.java b/io3/src/main/java/org/scijava/io3/location/LocationResolver.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/LocationResolver.java rename to io3/src/main/java/org/scijava/io3/location/LocationResolver.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/Locations.java b/io3/src/main/java/org/scijava/io3/location/Locations.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/Locations.java rename to io3/src/main/java/org/scijava/io3/location/Locations.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/RemoteLocation.java b/io3/src/main/java/org/scijava/io3/location/RemoteLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/RemoteLocation.java rename to io3/src/main/java/org/scijava/io3/location/RemoteLocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/URILocation.java b/io3/src/main/java/org/scijava/io3/location/URILocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/URILocation.java rename to io3/src/main/java/org/scijava/io3/location/URILocation.java diff --git a/scijava-io3/src/main/java/org/scijava/io3/location/URLLocation.java b/io3/src/main/java/org/scijava/io3/location/URLLocation.java similarity index 100% rename from scijava-io3/src/main/java/org/scijava/io3/location/URLLocation.java rename to io3/src/main/java/org/scijava/io3/location/URLLocation.java diff --git a/scijava-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 similarity index 100% rename from scijava-io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver rename to io3/src/main/resources/META-INF/services/org.scijava.io3.location.LocationResolver diff --git a/scijava-io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java b/io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java similarity index 100% rename from scijava-io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java rename to io3/src/test/java/org/scijava/io3/ByteBufferByteBankTest.java diff --git a/scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java b/io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java similarity index 100% rename from scijava-io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java rename to io3/src/test/java/org/scijava/io3/handle/DataHandlesTest.java diff --git a/scijava-io3/src/test/java/org/scijava/io3/location/LocationsTest.java b/io3/src/test/java/org/scijava/io3/location/LocationsTest.java similarity index 100% rename from scijava-io3/src/test/java/org/scijava/io3/location/LocationsTest.java rename to io3/src/test/java/org/scijava/io3/location/LocationsTest.java diff --git a/scijava-javafx/pom.xml b/javafx/pom.xml similarity index 100% rename from scijava-javafx/pom.xml rename to javafx/pom.xml diff --git a/scijava-javafx/src/main/java/module-info.java b/javafx/src/main/java/module-info.java similarity index 100% rename from scijava-javafx/src/main/java/module-info.java rename to javafx/src/main/java/module-info.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxChoice.java b/javafx/src/main/java/org/scijava/javafx/FxChoice.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxChoice.java rename to javafx/src/main/java/org/scijava/javafx/FxChoice.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxDialog.java b/javafx/src/main/java/org/scijava/javafx/FxDialog.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxDialog.java rename to javafx/src/main/java/org/scijava/javafx/FxDialog.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxFile.java b/javafx/src/main/java/org/scijava/javafx/FxFile.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxFile.java rename to javafx/src/main/java/org/scijava/javafx/FxFile.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java b/javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java rename to javafx/src/main/java/org/scijava/javafx/FxInputHarvester.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java b/javafx/src/main/java/org/scijava/javafx/FxMenus.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxMenus.java rename to javafx/src/main/java/org/scijava/javafx/FxMenus.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxMessage.java b/javafx/src/main/java/org/scijava/javafx/FxMessage.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxMessage.java rename to javafx/src/main/java/org/scijava/javafx/FxMessage.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxNumber.java b/javafx/src/main/java/org/scijava/javafx/FxNumber.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxNumber.java rename to javafx/src/main/java/org/scijava/javafx/FxNumber.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxPanel.java b/javafx/src/main/java/org/scijava/javafx/FxPanel.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxPanel.java rename to javafx/src/main/java/org/scijava/javafx/FxPanel.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxText.java b/javafx/src/main/java/org/scijava/javafx/FxText.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxText.java rename to javafx/src/main/java/org/scijava/javafx/FxText.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java b/javafx/src/main/java/org/scijava/javafx/FxThread.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxThread.java rename to javafx/src/main/java/org/scijava/javafx/FxThread.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxToggle.java b/javafx/src/main/java/org/scijava/javafx/FxToggle.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxToggle.java rename to javafx/src/main/java/org/scijava/javafx/FxToggle.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java b/javafx/src/main/java/org/scijava/javafx/FxWidget.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxWidget.java rename to javafx/src/main/java/org/scijava/javafx/FxWidget.java diff --git a/scijava-javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java b/javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java similarity index 100% rename from scijava-javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java rename to javafx/src/main/java/org/scijava/javafx/FxWidgetFactory.java diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java b/javafx/src/test/java/org/scijava/javafx/FxMenusTest.java similarity index 100% rename from scijava-javafx/src/test/java/org/scijava/javafx/FxMenusTest.java rename to javafx/src/test/java/org/scijava/javafx/FxMenusTest.java diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java b/javafx/src/test/java/org/scijava/javafx/FxShell.java similarity index 100% rename from scijava-javafx/src/test/java/org/scijava/javafx/FxShell.java rename to javafx/src/test/java/org/scijava/javafx/FxShell.java diff --git a/scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java b/javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java similarity index 100% rename from scijava-javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java rename to javafx/src/test/java/org/scijava/javafx/FxWidgetsTest.java diff --git a/scijava-javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy similarity index 100% rename from scijava-javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy rename to javafx/src/test/resources/scripts/Process/Filters/Blur_It.groovy diff --git a/scijava-javafx/src/test/resources/scripts/Say_Hello.groovy b/javafx/src/test/resources/scripts/Say_Hello.groovy similarity index 100% rename from scijava-javafx/src/test/resources/scripts/Say_Hello.groovy rename to javafx/src/test/resources/scripts/Say_Hello.groovy 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 100% rename from scijava-meta/pom.xml rename to meta/pom.xml diff --git a/scijava-meta/src/main/java/module-info.java b/meta/src/main/java/module-info.java similarity index 100% rename from scijava-meta/src/main/java/module-info.java rename to meta/src/main/java/module-info.java 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 100% rename from scijava-meta/src/main/java/org/scijava/meta/Manifest.java rename to meta/src/main/java/org/scijava/meta/Manifest.java 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 100% rename from scijava-meta/src/main/java/org/scijava/meta/POM.java rename to meta/src/main/java/org/scijava/meta/POM.java 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 100% rename from scijava-ops-api/pom.xml rename to ops-api/pom.xml diff --git a/scijava-ops-api/src/main/java/module-info.java b/ops-api/src/main/java/module-info.java similarity index 100% rename from scijava-ops-api/src/main/java/module-info.java rename to ops-api/src/main/java/module-info.java 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 100% 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 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 100% 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 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 100% 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 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-ops-benchmarks/LICENSE.txt b/ops-benchmarks/LICENSE.txt similarity index 100% rename from scijava-ops-benchmarks/LICENSE.txt rename to ops-benchmarks/LICENSE.txt diff --git a/scijava-ops-benchmarks/README.md b/ops-benchmarks/README.md similarity index 100% rename from scijava-ops-benchmarks/README.md rename to ops-benchmarks/README.md diff --git a/scijava-ops-benchmarks/pom.xml b/ops-benchmarks/pom.xml similarity index 100% rename from scijava-ops-benchmarks/pom.xml rename to ops-benchmarks/pom.xml 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-compat/LICENSE.txt b/ops-compat/LICENSE.txt similarity index 100% rename from scijava-ops-compat/LICENSE.txt rename to ops-compat/LICENSE.txt diff --git a/scijava-ops-compat/README.md b/ops-compat/README.md similarity index 100% rename from scijava-ops-compat/README.md rename to ops-compat/README.md diff --git a/scijava-ops-compat/pom.xml b/ops-compat/pom.xml similarity index 100% rename from scijava-ops-compat/pom.xml rename to ops-compat/pom.xml diff --git a/scijava-ops-compat/src/main/java/module-info.java b/ops-compat/src/main/java/module-info.java similarity index 100% rename from scijava-ops-compat/src/main/java/module-info.java rename to ops-compat/src/main/java/module-info.java diff --git a/scijava-ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java b/ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java similarity index 100% rename from scijava-ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java rename to ops-compat/src/main/java/org/scijava/ops/compat/DefaultOpEnvironmentService.java diff --git a/scijava-ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java b/ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java similarity index 100% rename from scijava-ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java rename to ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentPreprocessorPlugin.java diff --git a/scijava-ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java b/ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java similarity index 100% rename from scijava-ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java rename to ops-compat/src/main/java/org/scijava/ops/compat/OpEnvironmentService.java diff --git a/scijava-ops-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor b/ops-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor similarity index 100% rename from scijava-ops-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor rename to ops-compat/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor diff --git a/scijava-ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java b/ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java similarity index 100% rename from scijava-ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java rename to ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentPreprocessorTest.java diff --git a/scijava-ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java b/ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java similarity index 100% rename from scijava-ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java rename to ops-compat/src/test/java/org/scijava/ops/compat/OpEnvironmentServiceTest.java 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 100% rename from scijava-ops-engine/pom.xml rename to ops-engine/pom.xml 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 100% rename from scijava-ops-engine/src/main/java/module-info.java rename to ops-engine/src/main/java/module-info.java 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% rename from scijava-ops-ext-parser/pom.xml rename to ops-ext-parser/pom.xml 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 100% rename from scijava-ops-flim/pom.xml rename to ops-flim/pom.xml 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.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.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 100% rename from scijava-ops-image/README.md rename to ops-image/README.md 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 100% rename from scijava-ops-image/pom.xml rename to ops-image/pom.xml 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% rename from scijava-ops-indexer/README.md rename to ops-indexer/README.md 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 100% rename from scijava-ops-opencv/pom.xml rename to ops-opencv/pom.xml 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 100% rename from scijava-ops-tutorial/pom.xml rename to ops-tutorial/pom.xml 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 100% rename from scijava-ops-tutorial/scripts/ops-setup.py rename to ops-tutorial/scripts/ops-setup.py 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 49afdf6b2..4f7ce2607 100644 --- a/pom.xml +++ b/pom.xml @@ -53,52 +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-awt - scijava-bridge - scijava-compat - scijava-cli - scijava-collections - scijava-command3 - scijava-common3 - scijava-context - scijava-convert3 - scijava-context-test - scijava-concurrent - scijava-discovery - scijava-discovery-test - scijava-events - scijava-execute - scijava-function - scijava-harvest - scijava-index - scijava-io3 - scijava-javafx - scijava-imagej-compat - scijava-ops-compat - 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-script3 - scijava-settings - scijava-spi - scijava-struct - scijava-swing - scijava-testutil - scijava-types - scijava-ui3 - scijava-ui3-test + 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 @@ -128,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 100% rename from scijava-progress/src/main/java/org/scijava/progress/Progress.java rename to progress/src/main/java/org/scijava/progress/Progress.java 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/scijava-progress/src/test/java/org/scijava/progress/CancellationTest.java b/progress/src/test/java/org/scijava/progress/CancellationTest.java similarity index 100% rename from scijava-progress/src/test/java/org/scijava/progress/CancellationTest.java rename to progress/src/test/java/org/scijava/progress/CancellationTest.java 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-script3/pom.xml b/script3/pom.xml similarity index 100% rename from scijava-script3/pom.xml rename to script3/pom.xml diff --git a/scijava-script3/src/main/java/module-info.java b/script3/src/main/java/module-info.java similarity index 100% rename from scijava-script3/src/main/java/module-info.java rename to script3/src/main/java/module-info.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/JsrLanguage.java b/script3/src/main/java/org/scijava/script3/JsrLanguage.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/JsrLanguage.java rename to script3/src/main/java/org/scijava/script3/JsrLanguage.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptException.java b/script3/src/main/java/org/scijava/script3/ScriptException.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/ScriptException.java rename to script3/src/main/java/org/scijava/script3/ScriptException.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptExecutable.java b/script3/src/main/java/org/scijava/script3/ScriptExecutable.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/ScriptExecutable.java rename to script3/src/main/java/org/scijava/script3/ScriptExecutable.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java b/script3/src/main/java/org/scijava/script3/ScriptFinder.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/ScriptFinder.java rename to script3/src/main/java/org/scijava/script3/ScriptFinder.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java b/script3/src/main/java/org/scijava/script3/ScriptHeader.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/ScriptHeader.java rename to script3/src/main/java/org/scijava/script3/ScriptHeader.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptLanguage.java b/script3/src/main/java/org/scijava/script3/ScriptLanguage.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/ScriptLanguage.java rename to script3/src/main/java/org/scijava/script3/ScriptLanguage.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/ScriptSession.java b/script3/src/main/java/org/scijava/script3/ScriptSession.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/ScriptSession.java rename to script3/src/main/java/org/scijava/script3/ScriptSession.java diff --git a/scijava-script3/src/main/java/org/scijava/script3/Scripts.java b/script3/src/main/java/org/scijava/script3/Scripts.java similarity index 100% rename from scijava-script3/src/main/java/org/scijava/script3/Scripts.java rename to script3/src/main/java/org/scijava/script3/Scripts.java diff --git a/scijava-script3/src/test/java/org/scijava/script3/GroovyScriptTest.java b/script3/src/test/java/org/scijava/script3/GroovyScriptTest.java similarity index 100% rename from scijava-script3/src/test/java/org/scijava/script3/GroovyScriptTest.java rename to script3/src/test/java/org/scijava/script3/GroovyScriptTest.java diff --git a/scijava-script3/src/test/java/org/scijava/script3/ScriptFinderTest.java b/script3/src/test/java/org/scijava/script3/ScriptFinderTest.java similarity index 100% rename from scijava-script3/src/test/java/org/scijava/script3/ScriptFinderTest.java rename to script3/src/test/java/org/scijava/script3/ScriptFinderTest.java diff --git a/scijava-script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java b/script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java similarity index 100% rename from scijava-script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java rename to script3/src/test/java/org/scijava/script3/ScriptHeaderTest.java diff --git a/scijava-script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy similarity index 100% rename from scijava-script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy rename to script3/src/test/resources/scripts/Process/Filters/Blur_It.groovy diff --git a/scijava-script3/src/test/resources/scripts/Say_Hello.groovy b/script3/src/test/resources/scripts/Say_Hello.groovy similarity index 100% rename from scijava-script3/src/test/resources/scripts/Say_Hello.groovy rename to script3/src/test/resources/scripts/Say_Hello.groovy diff --git a/scijava-settings/pom.xml b/settings/pom.xml similarity index 100% rename from scijava-settings/pom.xml rename to settings/pom.xml diff --git a/scijava-settings/src/main/java/module-info.java b/settings/src/main/java/module-info.java similarity index 100% rename from scijava-settings/src/main/java/module-info.java rename to settings/src/main/java/module-info.java diff --git a/scijava-settings/src/main/java/org/scijava/settings/LoadInputs.java b/settings/src/main/java/org/scijava/settings/LoadInputs.java similarity index 100% rename from scijava-settings/src/main/java/org/scijava/settings/LoadInputs.java rename to settings/src/main/java/org/scijava/settings/LoadInputs.java diff --git a/scijava-settings/src/main/java/org/scijava/settings/RememberedInputs.java b/settings/src/main/java/org/scijava/settings/RememberedInputs.java similarity index 100% rename from scijava-settings/src/main/java/org/scijava/settings/RememberedInputs.java rename to settings/src/main/java/org/scijava/settings/RememberedInputs.java diff --git a/scijava-settings/src/main/java/org/scijava/settings/SaveInputs.java b/settings/src/main/java/org/scijava/settings/SaveInputs.java similarity index 100% rename from scijava-settings/src/main/java/org/scijava/settings/SaveInputs.java rename to settings/src/main/java/org/scijava/settings/SaveInputs.java diff --git a/scijava-settings/src/main/java/org/scijava/settings/Settings.java b/settings/src/main/java/org/scijava/settings/Settings.java similarity index 100% rename from scijava-settings/src/main/java/org/scijava/settings/Settings.java rename to settings/src/main/java/org/scijava/settings/Settings.java diff --git a/scijava-settings/src/main/java/org/scijava/settings/Toml.java b/settings/src/main/java/org/scijava/settings/Toml.java similarity index 100% rename from scijava-settings/src/main/java/org/scijava/settings/Toml.java rename to settings/src/main/java/org/scijava/settings/Toml.java diff --git a/scijava-settings/src/main/java/org/scijava/settings/TomlException.java b/settings/src/main/java/org/scijava/settings/TomlException.java similarity index 100% rename from scijava-settings/src/main/java/org/scijava/settings/TomlException.java rename to settings/src/main/java/org/scijava/settings/TomlException.java diff --git a/scijava-settings/src/test/java/org/scijava/settings/RememberedInputsTest.java b/settings/src/test/java/org/scijava/settings/RememberedInputsTest.java similarity index 100% rename from scijava-settings/src/test/java/org/scijava/settings/RememberedInputsTest.java rename to settings/src/test/java/org/scijava/settings/RememberedInputsTest.java diff --git a/scijava-settings/src/test/java/org/scijava/settings/SettingsTest.java b/settings/src/test/java/org/scijava/settings/SettingsTest.java similarity index 100% rename from scijava-settings/src/test/java/org/scijava/settings/SettingsTest.java rename to settings/src/test/java/org/scijava/settings/SettingsTest.java diff --git a/scijava-settings/src/test/java/org/scijava/settings/TomlTest.java b/settings/src/test/java/org/scijava/settings/TomlTest.java similarity index 100% rename from scijava-settings/src/test/java/org/scijava/settings/TomlTest.java rename to settings/src/test/java/org/scijava/settings/TomlTest.java diff --git a/scijava-spi/pom.xml b/spi/pom.xml similarity index 100% rename from scijava-spi/pom.xml rename to spi/pom.xml diff --git a/scijava-spi/src/main/java/module-info.java b/spi/src/main/java/module-info.java similarity index 100% rename from scijava-spi/src/main/java/module-info.java rename to spi/src/main/java/module-info.java diff --git a/scijava-spi/src/main/java/org/scijava/spi/Disposable.java b/spi/src/main/java/org/scijava/spi/Disposable.java similarity index 100% rename from scijava-spi/src/main/java/org/scijava/spi/Disposable.java rename to spi/src/main/java/org/scijava/spi/Disposable.java diff --git a/scijava-spi/src/main/java/org/scijava/spi/GenericTyped.java b/spi/src/main/java/org/scijava/spi/GenericTyped.java similarity index 100% rename from scijava-spi/src/main/java/org/scijava/spi/GenericTyped.java rename to spi/src/main/java/org/scijava/spi/GenericTyped.java diff --git a/scijava-spi/src/main/java/org/scijava/spi/Identifiable.java b/spi/src/main/java/org/scijava/spi/Identifiable.java similarity index 100% rename from scijava-spi/src/main/java/org/scijava/spi/Identifiable.java rename to spi/src/main/java/org/scijava/spi/Identifiable.java diff --git a/scijava-spi/src/main/java/org/scijava/spi/Named.java b/spi/src/main/java/org/scijava/spi/Named.java similarity index 100% rename from scijava-spi/src/main/java/org/scijava/spi/Named.java rename to spi/src/main/java/org/scijava/spi/Named.java diff --git a/scijava-spi/src/main/java/org/scijava/spi/Versioned.java b/spi/src/main/java/org/scijava/spi/Versioned.java similarity index 100% rename from scijava-spi/src/main/java/org/scijava/spi/Versioned.java rename to spi/src/main/java/org/scijava/spi/Versioned.java 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 100% rename from scijava-struct/README.md rename to struct/README.md 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/scijava-swing/pom.xml b/swing/pom.xml similarity index 100% rename from scijava-swing/pom.xml rename to swing/pom.xml diff --git a/scijava-swing/src/main/java/module-info.java b/swing/src/main/java/module-info.java similarity index 100% rename from scijava-swing/src/main/java/module-info.java rename to swing/src/main/java/module-info.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java b/swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java rename to swing/src/main/java/org/scijava/swing/SwingChoiceWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingChoiceWidgetFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java b/swing/src/main/java/org/scijava/swing/SwingDialog.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingDialog.java rename to swing/src/main/java/org/scijava/swing/SwingDialog.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingFileWidget.java b/swing/src/main/java/org/scijava/swing/SwingFileWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingFileWidget.java rename to swing/src/main/java/org/scijava/swing/SwingFileWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingFileWidgetFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java b/swing/src/main/java/org/scijava/swing/SwingInputHarvester.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingInputHarvester.java rename to swing/src/main/java/org/scijava/swing/SwingInputHarvester.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java b/swing/src/main/java/org/scijava/swing/SwingMenus.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingMenus.java rename to swing/src/main/java/org/scijava/swing/SwingMenus.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidget.java b/swing/src/main/java/org/scijava/swing/SwingMessageWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidget.java rename to swing/src/main/java/org/scijava/swing/SwingMessageWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingMessageWidgetFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java b/swing/src/main/java/org/scijava/swing/SwingNumberWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidget.java rename to swing/src/main/java/org/scijava/swing/SwingNumberWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingNumberWidgetFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingPanel.java b/swing/src/main/java/org/scijava/swing/SwingPanel.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingPanel.java rename to swing/src/main/java/org/scijava/swing/SwingPanel.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingPanelFactory.java b/swing/src/main/java/org/scijava/swing/SwingPanelFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingPanelFactory.java rename to swing/src/main/java/org/scijava/swing/SwingPanelFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java b/swing/src/main/java/org/scijava/swing/SwingTextWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingTextWidget.java rename to swing/src/main/java/org/scijava/swing/SwingTextWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingTextWidgetFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidget.java b/swing/src/main/java/org/scijava/swing/SwingToggleWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidget.java rename to swing/src/main/java/org/scijava/swing/SwingToggleWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingToggleWidgetFactory.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java b/swing/src/main/java/org/scijava/swing/SwingWidget.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingWidget.java rename to swing/src/main/java/org/scijava/swing/SwingWidget.java diff --git a/scijava-swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java b/swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java similarity index 100% rename from scijava-swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java rename to swing/src/main/java/org/scijava/swing/SwingWidgetFactory.java diff --git a/scijava-swing/src/test/java/org/scijava/swing/Demos.java b/swing/src/test/java/org/scijava/swing/Demos.java similarity index 100% rename from scijava-swing/src/test/java/org/scijava/swing/Demos.java rename to swing/src/test/java/org/scijava/swing/Demos.java diff --git a/scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java b/swing/src/test/java/org/scijava/swing/ScriptMenuTest.java similarity index 100% rename from scijava-swing/src/test/java/org/scijava/swing/ScriptMenuTest.java rename to swing/src/test/java/org/scijava/swing/ScriptMenuTest.java diff --git a/scijava-swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java b/swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java similarity index 100% rename from scijava-swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java rename to swing/src/test/java/org/scijava/swing/ScriptWidgetsTest.java diff --git a/scijava-swing/src/test/java/org/scijava/swing/Shell.java b/swing/src/test/java/org/scijava/swing/Shell.java similarity index 100% rename from scijava-swing/src/test/java/org/scijava/swing/Shell.java rename to swing/src/test/java/org/scijava/swing/Shell.java diff --git a/scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java b/swing/src/test/java/org/scijava/swing/SwingMenusTest.java similarity index 100% rename from scijava-swing/src/test/java/org/scijava/swing/SwingMenusTest.java rename to swing/src/test/java/org/scijava/swing/SwingMenusTest.java diff --git a/scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java b/swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java similarity index 100% rename from scijava-swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java rename to swing/src/test/java/org/scijava/swing/SwingWidgetsTest.java diff --git a/scijava-swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy b/swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy similarity index 100% rename from scijava-swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy rename to swing/src/test/resources/scripts/Process/Filters/Blur_It.groovy diff --git a/scijava-swing/src/test/resources/scripts/Say_Hello.groovy b/swing/src/test/resources/scripts/Say_Hello.groovy similarity index 100% rename from scijava-swing/src/test/resources/scripts/Say_Hello.groovy rename to swing/src/test/resources/scripts/Say_Hello.groovy 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 100% rename from scijava-testutil/README.md rename to testutil/README.md 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 100% rename from scijava-types/README.md rename to types/README.md diff --git a/scijava-types/pom.xml b/types/pom.xml similarity index 100% rename from scijava-types/pom.xml rename to types/pom.xml diff --git a/scijava-types/src/main/java/module-info.java b/types/src/main/java/module-info.java similarity index 100% rename from scijava-types/src/main/java/module-info.java rename to types/src/main/java/module-info.java 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 100% rename from scijava-types/src/main/java/org/scijava/types/Nil.java rename to types/src/main/java/org/scijava/types/Nil.java 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 100% 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 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 100% 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 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 100% rename from scijava-types/src/test/java/org/scijava/types/NilTest.java rename to types/src/test/java/org/scijava/types/NilTest.java 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 100% 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 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/scijava-ui3-test/pom.xml b/ui3-test/pom.xml similarity index 100% rename from scijava-ui3-test/pom.xml rename to ui3-test/pom.xml diff --git a/scijava-ui3-test/src/main/java/module-info.java b/ui3-test/src/main/java/module-info.java similarity index 100% rename from scijava-ui3-test/src/main/java/module-info.java rename to ui3-test/src/main/java/module-info.java diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java b/ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java similarity index 100% rename from scijava-ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java rename to ui3-test/src/main/java/org/scijava/ui3/test/MenuConformance.java diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java b/ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java similarity index 100% rename from scijava-ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java rename to ui3-test/src/main/java/org/scijava/ui3/test/SampleCommands.java diff --git a/scijava-ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java b/ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java similarity index 100% rename from scijava-ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java rename to ui3-test/src/main/java/org/scijava/ui3/test/WidgetConformance.java diff --git a/scijava-ui3/pom.xml b/ui3/pom.xml similarity index 100% rename from scijava-ui3/pom.xml rename to ui3/pom.xml diff --git a/scijava-ui3/src/main/java/module-info.java b/ui3/src/main/java/module-info.java similarity index 100% rename from scijava-ui3/src/main/java/module-info.java rename to ui3/src/main/java/module-info.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/AbstractWidget.java b/ui3/src/main/java/org/scijava/ui3/AbstractWidget.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/AbstractWidget.java rename to ui3/src/main/java/org/scijava/ui3/AbstractWidget.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/InputHarvester.java b/ui3/src/main/java/org/scijava/ui3/InputHarvester.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/InputHarvester.java rename to ui3/src/main/java/org/scijava/ui3/InputHarvester.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/Widget.java b/ui3/src/main/java/org/scijava/ui3/Widget.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/Widget.java rename to ui3/src/main/java/org/scijava/ui3/Widget.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetFactory.java b/ui3/src/main/java/org/scijava/ui3/WidgetFactory.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/WidgetFactory.java rename to ui3/src/main/java/org/scijava/ui3/WidgetFactory.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java b/ui3/src/main/java/org/scijava/ui3/WidgetPanel.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanel.java rename to ui3/src/main/java/org/scijava/ui3/WidgetPanel.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java b/ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java rename to ui3/src/main/java/org/scijava/ui3/WidgetPanelFactory.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java b/ui3/src/main/java/org/scijava/ui3/WidgetPanels.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/WidgetPanels.java rename to ui3/src/main/java/org/scijava/ui3/WidgetPanels.java diff --git a/scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java b/ui3/src/main/java/org/scijava/ui3/Widgets.java similarity index 100% rename from scijava-ui3/src/main/java/org/scijava/ui3/Widgets.java rename to ui3/src/main/java/org/scijava/ui3/Widgets.java From 680d702fea767b2ab4797b1d3058d259a562ecbc Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 25 Sep 2026 12:50:52 -0500 Subject: [PATCH 52/59] Make Context.doDispose() less deadlock-prone We only need to hold the lock for the duration of the disposed flag check; holding it for the entirety of the disposal operation could deadlock in some circumstances, such as when context.dispose() is called from within a WindowEvent listener's windowClosing method. As per scijava/scijava-common@5dba1000e3d0ad639715749fdd8fdfb59a835d00. Co-Authored-By: Claude Sonnet 5 --- .../java/org/scijava/context/Context.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/context/src/main/java/org/scijava/context/Context.java b/context/src/main/java/org/scijava/context/Context.java index 320e25171..e152629e8 100644 --- a/context/src/main/java/org/scijava/context/Context.java +++ b/context/src/main/java/org/scijava/context/Context.java @@ -89,7 +89,7 @@ public class Context implements Disposable, AutoCloseable { /** Plugin discovery, from the annotation index. Built on first use. */ private IndexDiscoverer pluginDiscoverer; - private boolean disposed; + private volatile boolean disposed; private Context(final List> providers) { this.providers = providers; @@ -282,20 +282,31 @@ public synchronized boolean isDisposed() { *

*/ @Override - public synchronized void dispose() { + public void dispose() { if (disposed) return; - disposed = true; + 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 = created.size() - 1; i >= 0; i--) { + for (int i = toDispose.size() - 1; i >= 0; i--) { try { - created.get(i).dispose(); + toDispose.get(i).dispose(); } catch (final Throwable exc) { failures.add(exc); } } - created.clear(); - byRequestedType.clear(); // NB: closing the bus is what frees services from arranging their own // unsubscription. events.close(); From a6419879b2294a19e3543e6499bccc4c0c100e9b Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 26 Oct 2026 12:57:41 -0500 Subject: [PATCH 53/59] Migrate incarnational versioning writeup to here Before now, it resided in the README of the SciJava incubator project. Co-authored-by: Claude Opus 5.5 --- cli/pom.xml | 2 +- compat/pom.xml | 2 +- convert3/pom.xml | 2 +- docs/conf.py | 1 + docs/migration-plan.md | 3 +- docs/versioning.md | 139 +++++++++++++++++++++++++++++++++++++++++ io3/pom.xml | 2 +- ops-indexer/README.md | 2 +- script3/pom.xml | 2 +- settings/pom.xml | 2 +- 10 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 docs/versioning.md diff --git a/cli/pom.xml b/cli/pom.xml index a9f4906ad..3c74029d0 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -11,7 +11,7 @@ scijava-cli diff --git a/compat/pom.xml b/compat/pom.xml index a20089609..10af03168 100644 --- a/compat/pom.xml +++ b/compat/pom.xml @@ -11,7 +11,7 @@ scijava-compat diff --git a/convert3/pom.xml b/convert3/pom.xml index c378904a8..00f871a90 100644 --- a/convert3/pom.xml +++ b/convert3/pom.xml @@ -11,7 +11,7 @@ scijava-convert3 diff --git a/docs/conf.py b/docs/conf.py index e3754be1a..5635c04c0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -68,6 +68,7 @@ "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 index 5ccc346ba..c1784c4b2 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -46,8 +46,7 @@ The deliverable is not a reimplementation of SJC. It is: 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 the - [incubator README](https://github.com/scijava/incubator/blob/-/README.md). + 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 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/io3/pom.xml b/io3/pom.xml index 3dc6bed1c..6383442ad 100644 --- a/io3/pom.xml +++ b/io3/pom.xml @@ -11,7 +11,7 @@ scijava-io3 diff --git a/ops-indexer/README.md b/ops-indexer/README.md index c5b20fd00..910e991ac 100644 --- a/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 diff --git a/script3/pom.xml b/script3/pom.xml index e4c40164f..b4e0544b6 100644 --- a/script3/pom.xml +++ b/script3/pom.xml @@ -11,7 +11,7 @@ scijava-script3 diff --git a/settings/pom.xml b/settings/pom.xml index 5c7ac51ab..5dc980ac7 100644 --- a/settings/pom.xml +++ b/settings/pom.xml @@ -11,7 +11,7 @@ scijava-settings From e625d355b2e2f5f80eae327cbd50e3f410e24b5f Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 26 Oct 2026 12:58:20 -0500 Subject: [PATCH 54/59] README: fix build badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From ee70467c9a04de594a5ab9b2c7bcd8ad59837893 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 26 Oct 2026 12:58:50 -0500 Subject: [PATCH 55/59] Fix wrong suggested scijava-ops-indexer version --- ops-indexer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops-indexer/README.md b/ops-indexer/README.md index 910e991ac..03cd91661 100644 --- a/ops-indexer/README.md +++ b/ops-indexer/README.md @@ -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 From a3f8e24d7c4943eaac0f124eba8a281085136856 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 26 Oct 2026 12:59:41 -0500 Subject: [PATCH 56/59] Pin the migration plan's scijava-persist version --- docs/migration-plan.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/migration-plan.md b/docs/migration-plan.md index c1784c4b2..23f437ac2 100644 --- a/docs/migration-plan.md +++ b/docs/migration-plan.md @@ -1431,16 +1431,19 @@ design settled now, so that Phase 4 inherits decisions rather than a debate: ## Later, not now -- **`scijava-persist`** (in the incubator): 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. +- **`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 From 289a91c0ce90b5e15e75ac446a4bb560f2e84c2e Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Mon, 26 Oct 2026 15:23:16 -0500 Subject: [PATCH 57/59] Fix javadoc errors --- discovery/src/main/java/org/scijava/discovery/Discoverer.java | 2 +- .../main/java/org/scijava/execute/FieldParameterMember.java | 1 + .../src/main/java/org/scijava/index/AnnotationProcessor.java | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/discovery/src/main/java/org/scijava/discovery/Discoverer.java b/discovery/src/main/java/org/scijava/discovery/Discoverer.java index 42e3dd678..136122fc6 100644 --- a/discovery/src/main/java/org/scijava/discovery/Discoverer.java +++ b/discovery/src/main/java/org/scijava/discovery/Discoverer.java @@ -117,7 +117,7 @@ public List> discover(final Class c) { /** * Creates a {@code Discoverer} from a function yielding already-constructed - * objects. Such a source cannot be lazy; prefer {@link #serviceLoader()} + * 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 diff --git a/execute/src/main/java/org/scijava/execute/FieldParameterMember.java b/execute/src/main/java/org/scijava/execute/FieldParameterMember.java index 52cd2f7bf..f463b5db5 100644 --- a/execute/src/main/java/org/scijava/execute/FieldParameterMember.java +++ b/execute/src/main/java/org/scijava/execute/FieldParameterMember.java @@ -40,6 +40,7 @@ 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; diff --git a/index/src/main/java/org/scijava/index/AnnotationProcessor.java b/index/src/main/java/org/scijava/index/AnnotationProcessor.java index 5ee1d6c3e..0e65b519d 100644 --- a/index/src/main/java/org/scijava/index/AnnotationProcessor.java +++ b/index/src/main/java/org/scijava/index/AnnotationProcessor.java @@ -81,8 +81,8 @@ * classpath scan. *

*

- * For example, SciJava Common's {@link org.scijava.plugin.Plugin} annotation - * enables the {@link org.scijava.Context} application container to discover + * 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. From e197fad98fb8d16b25190cd4a504416624ab80d6 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 28 Oct 2026 13:24:59 -0500 Subject: [PATCH 58/59] Make DiscoveryLazinessTest independent of test order ServiceBasedAdder.constructed is static, so another test constructing the adder first left the flag set before this test even began. Co-Authored-By: Claude Opus 5.5 --- .../java/org/scijava/discovery/test/DiscoveryLazinessTest.java | 3 +++ 1 file changed, 3 insertions(+) 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 index 1477a950a..afb0ef9fd 100644 --- a/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java +++ b/discovery-test/src/test/java/org/scijava/discovery/test/DiscoveryLazinessTest.java @@ -53,6 +53,9 @@ public class DiscoveryLazinessTest { */ @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()); From ba9188101ee51ce69036e9db6d6d77cadbb79369 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Wed, 28 Oct 2026 14:41:51 -0500 Subject: [PATCH 59/59] Address Copilot review feedback Fix the OpWrappersTest template's stale GenericTyped import, so that regenerating the test does not break it. Move the DatasetTypeExtractor service descriptor from ops-compat, which no longer contains that class, to imagej-compat, which does. And give the ops-flim descriptor the TypeExtractor's actual qualified name. Clarify why the DatasetTypeExtractorTest's reifier finds no extractors: its module does not declare that it uses TypeExtractor. Correct stale and wrong information in several READMEs, and fix typos. Co-Authored-By: Claude Opus 5.5 --- collections/README.md | 4 +-- discovery-test/README.md | 28 +++++-------------- discovery/README.md | 18 +++++++----- .../org.scijava.types.extract.TypeExtractor | 2 +- .../compat/DatasetTypeExtractorTest.java | 7 +++-- ops-benchmarks/README.md | 10 +++---- ops-compat/README.md | 18 +++++------- .../engine/util/internal/package-info.java | 2 +- .../ops/engine/matcher/OpWrappersTest.vm | 2 +- ...> org.scijava.types.extract.TypeExtractor} | 0 ops-image/README.md | 1 - ops-tutorial/scripts/ops-setup.py | 2 +- struct/README.md | 2 +- testutil/README.md | 2 +- types/README.md | 4 +-- 15 files changed, 44 insertions(+), 58 deletions(-) rename {ops-compat => imagej-compat}/src/main/resources/META-INF/services/org.scijava.types.extract.TypeExtractor (73%) rename ops-flim/src/main/resources/META-INF/services/{org.scijava.types.TypeExtractor => org.scijava.types.extract.TypeExtractor} (100%) diff --git a/collections/README.md b/collections/README.md index 23581cf0c..85391b021 100644 --- a/collections/README.md +++ b/collections/README.md @@ -1,5 +1,5 @@ # SciJava Collections -Useful implementions of `java.util.Collection`, e.g. for primitive arrays. +Useful implementations of `java.util.Collection`, e.g. for primitive arrays. -Not at full-featured as Trove, but simple. +Not as full-featured as Trove, but simple. diff --git a/discovery-test/README.md b/discovery-test/README.md index a95cab21c..1b3e5db20 100644 --- a/discovery-test/README.md +++ b/discovery-test/README.md @@ -1,26 +1,12 @@ -# 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. +# SciJava Discovery Test: Integration tests for SciJava Discovery -## What can `ServiceLoaderDiscoverer` discover? +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. -`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. +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. -# 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`: +Note that `ServiceLoader` can only discover implementations of interfaces that the calling module declares it `uses`. This module therefore declares: ```java -provides org.scijava.ops.spi.Op with com.example.foo.FooOp; -``` \ No newline at end of file +uses org.scijava.ops.spi.Op; +uses org.scijava.ops.spi.OpCollection; +``` diff --git a/discovery/README.md b/discovery/README.md index 2f8897fcf..62569989c 100644 --- a/discovery/README.md +++ b/discovery/README.md @@ -1,15 +1,19 @@ # 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: +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.implsOfType(Class c)` +## ` List> Discoverer.discover(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. +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.elementsTaggedWith(String tagType)` +## ` List Discoverer.instances(Class c)` -This method is designed to return a list of `AnnotatedElements` (`Class`es, `Method`s, or `Field`s) tagged with the given tag type. +A convenience for callers that simply want the objects: it constructs an instance from each `Discovery` returned by `discover(c)`. -## Tags +## Creating `Discoverer`s -** TODO** +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/ops-compat/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 ops-compat/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/ops-compat/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/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java b/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java index 447c9e280..4a1a91a71 100644 --- a/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java +++ b/imagej-compat/src/test/java/org/scijava/imagej/compat/DatasetTypeExtractorTest.java @@ -54,9 +54,10 @@ public class DatasetTypeExtractorTest { @Test public void testDatasetTypeExtractor() { // Create a TypeReifier - // NB the use of Discoverer.all(Discoverer.usingProviders(c -> ServiceLoader.load(c).stream())) finds no TypeExtractors - // because the scijava-legacy module does not use the TypeExtractor - // interface. Thus it cannot actually reify anything. + // 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); diff --git a/ops-benchmarks/README.md b/ops-benchmarks/README.md index ed225403c..b1b657ff5 100644 --- a/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/ops-compat/README.md b/ops-compat/README.md index 81580d001..4e45a3919 100644 --- a/ops-compat/README.md +++ b/ops-compat/README.md @@ -1,21 +1,17 @@ -# SciJava Legacy: A backwards compatibility bridge with older SciJava code +# SciJava Ops Compat: 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. +This library provides a bridge between SciJava Ops +and previous generations (Java 8 / pre-JPMS) of SciJava 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. +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`: -```java +```xml org.scijava - scijava-legacy + scijava-ops-compat 1.0.0 ``` diff --git a/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 index 0aa607e3e..65040b8e2 100644 --- a/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/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 index a748dafc4..ad1337594 100644 --- a/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/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 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/ops-image/README.md b/ops-image/README.md index 8dc2c8e3f..a987ffdf5 100644 --- a/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/ops-tutorial/scripts/ops-setup.py b/ops-tutorial/scripts/ops-setup.py index a2b5e745a..0fb583438 100644 --- a/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/struct/README.md b/struct/README.md index b4dec0d38..06d2c0839 100644 --- a/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/testutil/README.md b/testutil/README.md index a5a7a03a5..5dfd6a64a 100644 --- a/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/types/README.md b/types/README.md index 5ecc0b089..ad636a6c3 100644 --- a/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.