diff --git a/docs/concepts/commands.rst b/docs/concepts/commands.rst new file mode 100644 index 0000000000..631f8eb6f9 --- /dev/null +++ b/docs/concepts/commands.rst @@ -0,0 +1,121 @@ +.. _command: + +Command +======= + +A command is something a user can invoke by name: "Toggle Scale Bar", +"Screenshot", "Add Layer". Commands are named separately from the keys they are +bound to, so a command with no keyboard shortcut still shows up wherever +commands are listed. + +.. _command-object: + +Command +------- + +Defined in ``src/ui/command.ts``. + +A ``Command`` holds a stable ``id``, the ``label`` and optional ``description`` +shown in the UI, and an ``invoke`` method that runs it. Bindings, ordering and +grouping live elsewhere. + +Two implementations ship with the viewer: + +- ``ActionCommand`` dispatches ``action:`` at the invocation context's + dispatch target, the same event the matching key binding sends, so existing + ``registerActionListener`` handlers keep working. +- ``CallbackCommand`` runs a callback, for behaviour with no DOM action behind + it. This is the usual choice for an application embedding the viewer. + +``invoke`` takes a ``CommandContext`` rather than a bare target, which leaves +room to pass more context later (mouse position, originating layer) without +changing every implementation. + +Each command has a ``changed`` signal covering everything a consumer might +redraw for, so a consumer subscribes once per command rather than once per +property. ``enabled`` is currently the only property that changes. + +.. _command-registry: + +Command registry +---------------- + +Defined in ``src/ui/command_registry.ts``. + +The registry holds the commands a viewer knows about, and each viewer owns one. +Default viewer setup seeds it with the built-in set from +``src/ui/default_commands.ts``, and feature code registers its own commands +next to the feature they belong to: + +.. code-block:: typescript + + this.registerDisposer( + viewer.commandRegistry.register( + new CallbackCommand("add-clip-plane", "Add Clip Plane", () => + this.addPlane(), + ), + ), + ); + +``register`` returns a disposer, so commands can come and go with the feature +that owns them, a per-layer control for example. The registry re-dispatches its +``changed`` signal when a command is registered, unregistered, or reports a +change of its own. + +The registry lists the commands it was told about, and there is no way to make +that list complete. A viewer embedded in another application, or driven from the +Python integration, can bind an action without ever registering a command for +it. Consumers read the registry first and fall back to the bindings for the +rest. + +.. _command-catalog: + +Command catalog +--------------- + +Defined in ``src/ui/command_catalog.ts``. + +The catalog turns the registry plus the current viewer state into a flat, +ordered list of entries to present. It: + +- enumerates the registry, skipping disabled commands; +- looks up the live key binding for each command's id and attaches it as a + display-only ``shortcut``. A suggested binding stored on the command instead + would drift from whatever is installed; +- adds an entry for each keyboard-bound action that has no registered command, + labelled from its action id. This is what keeps actions contributed by an + embedder or from Python visible; +- contributes the entries that cannot be declared ahead of time: the layer + pickers (``Toggle Layer`` and friends, as sub-palette groups) and the tools + currently available; +- orders, groups and filters. + +Grouping commands into sections belongs here or further out. The palette and the +help panel would reasonably group the same commands in different ways, which is +why the registry carries no category of its own. + +The catalog rebuilds itself, debounced to an animation frame, whenever the +registry, the layers or the tool bindings change. + +.. _command-palette: + +Command palette +--------------- + +Defined in ``src/ui/command_palette.ts``. + +The palette owns the UI. It renders a catalog, lets the user search and step +into sub-palettes, and invokes the selected entry with a ``CommandContext`` +whose dispatch target is whichever element had focus when the palette opened. + +Update flow +----------- + +.. code-block:: text + + register / unregister, command.changed + │ + ▼ + CommandRegistry.changed ──▶ CommandCatalog rebuild ──▶ CommandCatalog.changed ──▶ CommandPalette re-render + ▲ + layer / tool binding changes┘ diff --git a/docs/index.rst b/docs/index.rst index ed12341c45..5bfacdbe25 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,6 +12,7 @@ Neuroglancer :hidden: :caption: Concepts + concepts/commands concepts/coordinate_spaces concepts/data_views concepts/layers diff --git a/src/ui/command.ts b/src/ui/command.ts new file mode 100644 index 0000000000..4d72688651 --- /dev/null +++ b/src/ui/command.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file A command: something the user can invoke, named independently of how it + * happens to be bound. + * + * A command owns its identity (`id`), how it presents (`label`, `description`) + * and how it runs (`invoke`). Bindings, ordering and grouping belong to the + * consumers that present it. See `docs/concepts/commands.rst` for how commands, + * {@link CommandRegistry} and {@link CommandCatalog} divide up the work. + */ + +import type { ActionIdentifier } from "#src/util/event_action_map.js"; +import { NullarySignal } from "#src/util/signal.js"; + +/** + * Stable, serialisable identifier of a command, e.g. "toggle-scale-bar". For an + * {@link ActionCommand} this is also the DOM action id, hence the shared type. + */ +export type CommandId = ActionIdentifier; + +/** + * What a command is told about the invocation. An interface rather than a bare + * target so that more context (mouse position, originating layer, …) can be + * added later without touching every implementation. + */ +export interface CommandContext { + /** + * Element the invocation is attributed to. For the command palette this is + * whichever element had focus when the palette was opened, so that a command + * acts on the panel the user was in. + */ + readonly dispatchTarget: EventTarget; +} + +/** + * Base class for commands. Subclass it to define how the command runs; the + * registry stores instances and consumers read `label`/`description` and call + * {@link invoke}. + */ +export abstract class Command { + /** + * Dispatched when anything a consumer renders or filters on changes. Bundling + * these into one signal means a consumer subscribes once per command rather + * than once per mutable property. Currently only {@link enabled} changes. + */ + readonly changed = new NullarySignal(); + + private enabled_ = true; + + constructor( + readonly id: CommandId, + readonly label: string, + readonly description?: string, + ) {} + + /** + * Whether the command can currently be invoked. Consumers are expected to + * omit disabled commands (the catalog does). Nothing built in flips this yet; + * it exists so a feature can register a command once and mark it unavailable + * while its preconditions are unmet, rather than register/unregister it. + */ + get enabled(): boolean { + return this.enabled_; + } + + set enabled(value: boolean) { + if (value === this.enabled_) return; + this.enabled_ = value; + this.changed.dispatch(); + } + + abstract invoke(context: CommandContext): void; +} + +/** + * A command backed by a DOM action: invoking it dispatches `action:` at the + * context's dispatch target, exactly as the equivalent key binding would, so + * existing `registerActionListener` handlers need no extra wiring. + */ +export class ActionCommand extends Command { + invoke({ dispatchTarget }: CommandContext) { + dispatchTarget.dispatchEvent( + new CustomEvent(`action:${this.id}`, { + bubbles: true, + cancelable: true, + detail: {}, + }), + ); + } +} + +/** + * A command that runs a callback directly, for behaviour with no corresponding + * DOM action, e.g. commands contributed by an application embedding the + * viewer. + */ +export class CallbackCommand extends Command { + constructor( + id: CommandId, + label: string, + private readonly callback: (context: CommandContext) => void, + description?: string, + ) { + super(id, label, description); + } + + invoke(context: CommandContext) { + this.callback(context); + } +} diff --git a/src/ui/command_catalog.spec.ts b/src/ui/command_catalog.spec.ts index 19480e561a..cb5f2b4fdd 100644 --- a/src/ui/command_catalog.spec.ts +++ b/src/ui/command_catalog.spec.ts @@ -14,12 +14,14 @@ * limitations under the License. */ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { ActionCommand } from "#src/ui/command.js"; import { collectActionBindings, CommandCatalog, type CommandCatalogContext, } from "#src/ui/command_catalog.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; import { EventActionMap } from "#src/util/event_action_map.js"; import { Signal } from "#src/util/signal.js"; import type { InputEventBindings } from "#src/viewer.js"; @@ -42,9 +44,18 @@ function makeInputEventBindings( const noopSignal = { add: () => () => {} }; +// Registries created by makeContext, disposed after each test. +const activeRegistries: CommandRegistry[] = []; + +afterEach(() => { + while (activeRegistries.length > 0) activeRegistries.pop()!.dispose(); +}); + function makeContext( inputEventBindings = makeInputEventBindings(new EventActionMap()), + commandRegistry = new CommandRegistry(), ): CommandCatalogContext { + activeRegistries.push(commandRegistry); return { globalToolBinder: { changed: noopSignal, @@ -59,6 +70,7 @@ function makeContext( }, selectedLayer: {}, inputEventBindings, + commandRegistry, } as unknown as CommandCatalogContext; } @@ -111,10 +123,15 @@ describe("collectActionBindings", () => { }); describe("CommandCatalog.filter", () => { - // With empty bindings the catalog contains only the two supplemental commands: - // "Edit JSON State" and "Screenshot". + // Seed the registry with two commands so the catalog surfaces exactly + // "Edit JSON State" and "Screenshot" as its flat entries. function makeCatalog() { - return new CommandCatalog(makeContext()); + const registry = new CommandRegistry(); + registry.register(new ActionCommand("edit-json-state", "Edit JSON State")); + registry.register(new ActionCommand("screenshot", "Screenshot")); + return new CommandCatalog( + makeContext(makeInputEventBindings(new EventActionMap()), registry), + ); } it("returns all commands for an empty query", () => { @@ -143,6 +160,95 @@ describe("CommandCatalog.filter", () => { }); }); +describe("CommandCatalog command sources", () => { + function makeCatalog(map: EventActionMap, registry: CommandRegistry) { + return new CommandCatalog( + makeContext(makeInputEventBindings(map), registry), + ); + } + + function commandEntries(catalog: CommandCatalog) { + return catalog.commands.filter((entry) => entry.kind === "command"); + } + + it("annotates a registered command with its live binding", () => { + const map = new EventActionMap(); + map.set("keyb", "toggle-scale-bar"); + const registry = new CommandRegistry(); + registry.register( + new ActionCommand("toggle-scale-bar", "Toggle Scale Bar"), + ); + const catalog = makeCatalog(map, registry); + try { + const entries = commandEntries(catalog).filter( + (entry) => entry.command.id === "toggle-scale-bar", + ); + expect(entries).toHaveLength(1); + expect(entries[0].label).toBe("Toggle Scale Bar"); + expect(entries[0].shortcut).toBe("b"); + } finally { + catalog.dispose(); + } + }); + + it("still lists a bound action that nothing registered", () => { + // An embedder (or the Python integration) may bind an action without + // registering a command for it; it should not vanish from the palette. + const map = new EventActionMap(); + map.set("keyq", "embedder-action"); + const catalog = makeCatalog(map, new CommandRegistry()); + try { + const entries = commandEntries(catalog).filter( + (entry) => entry.command.id === "embedder-action", + ); + expect(entries).toHaveLength(1); + // Falls back to a label derived from the action id. + expect(entries[0].label).toBe("Embedder Action"); + expect(entries[0].shortcut).toBe("q"); + } finally { + catalog.dispose(); + } + }); + + it("does not duplicate an action that is both registered and bound", () => { + const map = new EventActionMap(); + map.set("keyb", "toggle-scale-bar"); + const registry = new CommandRegistry(); + registry.register( + new ActionCommand("toggle-scale-bar", "Toggle Scale Bar"), + ); + const catalog = makeCatalog(map, registry); + try { + expect( + commandEntries(catalog).filter( + (entry) => entry.command.id === "toggle-scale-bar", + ), + ).toHaveLength(1); + } finally { + catalog.dispose(); + } + }); + + it("omits a disabled command, binding or not", () => { + const map = new EventActionMap(); + map.set("keyb", "toggle-scale-bar"); + const registry = new CommandRegistry(); + const command = new ActionCommand("toggle-scale-bar", "Toggle Scale Bar"); + command.enabled = false; + registry.register(command); + const catalog = makeCatalog(map, registry); + try { + expect( + commandEntries(catalog).filter( + (entry) => entry.command.id === "toggle-scale-bar", + ), + ).toHaveLength(0); + } finally { + catalog.dispose(); + } + }); +}); + describe("CommandCatalog reactivity", () => { it("rebuilds (debounced) when a subscribed change signal fires", async () => { const layersChanged = new Signal(); diff --git a/src/ui/command_catalog.ts b/src/ui/command_catalog.ts index aae27d53a1..72dd83fbd6 100644 --- a/src/ui/command_catalog.ts +++ b/src/ui/command_catalog.ts @@ -16,6 +16,9 @@ import type { LayerManager, SelectedLayerState } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; +import type { Command } from "#src/ui/command.js"; +import { ActionCommand } from "#src/ui/command.js"; +import type { CommandRegistry } from "#src/ui/command_registry.js"; import { getMatchingTools, restoreTool, @@ -39,16 +42,15 @@ export interface CommandCatalogContext { layerManager: LayerManager; selectedLayer: SelectedLayerState; inputEventBindings: InputEventBindings; + /** + * Primary source of the flat command set. Registered commands are enumerated + * directly and take precedence; any keyboard-bound action that is *not* + * registered is still listed afterwards, so actions an embedder or the Python + * integration only ever bound to a key do not disappear from the palette. + */ + commandRegistry: CommandRegistry; } -const SUPPLEMENTAL_COMMANDS: readonly { - actionId: ActionIdentifier; - label: string; -}[] = [ - { actionId: "edit-json-state", label: "Edit JSON State" }, - { actionId: "screenshot", label: "Screenshot" }, -]; - export interface ActionBinding { readonly actionId: ActionIdentifier; readonly eventAction: EventAction; @@ -59,14 +61,15 @@ interface CommandPaletteEntryBase { readonly shortcut: string; } -// Dispatched as an `action:` DOM event, exactly as the keyboard -// shortcut would be. -export interface ActionCommandEntry extends CommandPaletteEntryBase { - readonly kind: "action"; - readonly actionId: ActionIdentifier; +// A command, either taken from the registry or synthesised for a keyboard-bound +// action that nothing registered. The consumer invokes it with its own context. +export interface CommandEntry extends CommandPaletteEntryBase { + readonly kind: "command"; + readonly command: Command; } -// Runs a callback directly (no DOM action exists for it). +// Runs an anonymous callback directly (no command identity, e.g. a per-layer +// toggle or an unbound tool activation). export interface ExecuteCommandEntry extends CommandPaletteEntryBase { readonly kind: "execute"; readonly execute: () => void; @@ -79,7 +82,7 @@ export interface GroupCommandEntry extends CommandPaletteEntryBase { } export type CommandPaletteEntry = - | ActionCommandEntry + | CommandEntry | ExecuteCommandEntry | GroupCommandEntry; @@ -95,6 +98,7 @@ function formatKeyStroke(stroke: string): string { .join("+"); } +// Fallback label for a bound action that no command was registered for. function actionIdToLabel(actionId: ActionIdentifier): string { return actionId .split("-") @@ -270,6 +274,9 @@ export class CommandCatalog extends RefCounted { this.registerDisposer( context.layerManager.layersChanged.add(debouncedRebuild), ); + this.registerDisposer( + context.commandRegistry.changed.add(debouncedRebuild), + ); this.rebuild(); } @@ -279,17 +286,10 @@ export class CommandCatalog extends RefCounted { layerManager, selectedLayer, inputEventBindings, + commandRegistry, } = this.context; const commands: CommandPaletteEntry[] = []; - // "Deactivate Active Tool" is always present — harmless no-op when nothing is active. - commands.push({ - kind: "action", - label: "Deactivate Active Tool", - shortcut: "", - actionId: "deactivate-active-tool", - }); - // Hierarchical layer actions — each group entry opens a sub-palette of layers. // The first 9 layers carry their digit-key shortcuts so users can see they // still work directly from the keyboard without opening the sub-palette. @@ -347,20 +347,34 @@ export class CommandCatalog extends RefCounted { ); } - for (const { actionId, eventAction } of bindings) { + // Registered commands come first. A command's shortcut is whatever binding + // is currently installed for its id, shown for reference only. + for (const command of commandRegistry.values()) { + if (!command.enabled) continue; + commands.push({ + kind: "command", + label: command.label, + shortcut: shortcutByAction.get(command.id) ?? "", + command, + }); + } + + // The registry is not required to be exhaustive: an embedder (or the Python + // integration) may bind an action without registering a command for it. + // Those are listed too, labelled from their action id, so nothing that used + // to appear in the palette is lost. + for (const { actionId } of bindings) { + if (commandRegistry.has(actionId)) continue; if (/^tool-[A-Z]$/.test(actionId)) continue; // Layer-index actions are replaced by hierarchical group entries above. if (/^(toggle|select|toggle-pick)-layer-\d+$/.test(actionId)) continue; - const label = actionIdToLabel(actionId); - const shortcut = formatKeyStroke( - friendlyEventIdentifier(eventAction.originalEventIdentifier ?? ""), - ); - commands.push({ kind: "action", label, shortcut, actionId }); - } - - for (const { actionId, label } of SUPPLEMENTAL_COMMANDS) { - commands.push({ kind: "action", label, shortcut: "", actionId }); + commands.push({ + kind: "command", + label, + shortcut: shortcutByAction.get(actionId) ?? "", + command: new ActionCommand(actionId, label), + }); } const toolQueryResult = parseToolQuery("+"); @@ -402,10 +416,10 @@ export class CommandCatalog extends RefCounted { ? `${tool.description} — ${tool.context.managedLayer.name}` : tool.description; commands.push({ - kind: "action", + kind: "command", label, shortcut: shortcutByAction.get(actionId) ?? "", - actionId, + command: new ActionCommand(actionId, label), }); } else { const capturedToolJson = toolJson; diff --git a/src/ui/command_palette.ts b/src/ui/command_palette.ts index bee4a0743e..0fec7e3f20 100644 --- a/src/ui/command_palette.ts +++ b/src/ui/command_palette.ts @@ -279,14 +279,8 @@ export class CommandPalette extends Overlay { if (command.kind === "execute") { command.execute(); - } else if (command.kind === "action") { - this.actionDispatchTarget.dispatchEvent( - new CustomEvent(`action:${command.actionId}`, { - bubbles: true, - cancelable: true, - detail: {}, - }), - ); + } else if (command.kind === "command") { + command.command.invoke({ dispatchTarget: this.actionDispatchTarget }); } } } diff --git a/src/ui/command_registry.spec.ts b/src/ui/command_registry.spec.ts new file mode 100644 index 0000000000..a8fd962171 --- /dev/null +++ b/src/ui/command_registry.spec.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import type { CommandContext } from "#src/ui/command.js"; +import { ActionCommand, CallbackCommand } from "#src/ui/command.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; + +function makeContext(dispatchTarget: EventTarget = new EventTarget()) { + return { dispatchTarget } satisfies CommandContext; +} + +describe("Command", () => { + it("dispatches an action event for an action command", () => { + const target = document.createElement("div"); + const seen: string[] = []; + target.addEventListener("action:screenshot", () => seen.push("screenshot")); + new ActionCommand("screenshot", "Screenshot").invoke(makeContext(target)); + expect(seen).toStrictEqual(["screenshot"]); + }); + + it("passes the invocation context to a callback command", () => { + const target = new EventTarget(); + let seen: EventTarget | undefined; + new CallbackCommand("c", "C", (context) => { + seen = context.dispatchTarget; + }).invoke(makeContext(target)); + expect(seen).toBe(target); + }); + + it("dispatches changed when enabled flips, and only then", () => { + const command = new ActionCommand("a", "A"); + let count = 0; + command.changed.add(() => ++count); + expect(command.enabled).toBe(true); + command.enabled = true; + expect(count).toBe(0); + command.enabled = false; + expect(count).toBe(1); + }); +}); + +describe("CommandRegistry", () => { + it("registers and retrieves a command by id", () => { + const registry = new CommandRegistry(); + registry.register( + new ActionCommand("screenshot", "Screenshot", "Capture a screenshot."), + ); + expect(registry.has("screenshot")).toBe(true); + expect(registry.get("screenshot")?.label).toBe("Screenshot"); + expect(registry.get("screenshot")?.description).toBe( + "Capture a screenshot.", + ); + registry.dispose(); + }); + + it("enumerates commands in registration order, independent of any binding", () => { + const registry = new CommandRegistry(); + registry.register(new ActionCommand("a", "A")); + registry.register(new CallbackCommand("c", "C", () => {})); + expect([...registry.values()].map((command) => command.id)).toStrictEqual([ + "a", + "c", + ]); + registry.dispose(); + }); + + it("throws on duplicate id", () => { + const registry = new CommandRegistry(); + registry.register(new ActionCommand("dup", "First")); + expect(() => registry.register(new ActionCommand("dup", "Second"))).toThrow( + /already registered/, + ); + registry.dispose(); + }); + + it("unregisters via the returned disposer", () => { + const registry = new CommandRegistry(); + const dispose = registry.register(new ActionCommand("temp", "Temp")); + expect(registry.has("temp")).toBe(true); + dispose(); + expect(registry.has("temp")).toBe(false); + registry.dispose(); + }); + + it("dispatches changed on register and unregister", () => { + const registry = new CommandRegistry(); + let count = 0; + registry.changed.add(() => ++count); + const dispose = registry.register(new ActionCommand("x", "X")); + expect(count).toBe(1); + dispose(); + expect(count).toBe(2); + registry.dispose(); + }); + + it("forwards a registered command's own changed signal", () => { + const registry = new CommandRegistry(); + const command = new ActionCommand("x", "X"); + registry.register(command); + let count = 0; + registry.changed.add(() => ++count); + command.enabled = false; + expect(count).toBe(1); + registry.dispose(); + }); + + it("stops forwarding a command's changed signal after unregister", () => { + const registry = new CommandRegistry(); + const command = new ActionCommand("x", "X"); + const dispose = registry.register(command); + dispose(); + let count = 0; + registry.changed.add(() => ++count); + command.enabled = false; + expect(count).toBe(0); + registry.dispose(); + }); +}); diff --git a/src/ui/command_registry.ts b/src/ui/command_registry.ts new file mode 100644 index 0000000000..0fc1ee00e9 --- /dev/null +++ b/src/ui/command_registry.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Registry of the {@link Command}s a viewer knows about. + * + * The registry owns *what commands exist*: their id, how they present, and how + * they run. Bindings, ordering and grouping live with the consumers; a consumer + * that wants to show a shortcut looks the live binding up itself. + * + * The registry is owned by the viewer, so commands can be registered before any + * UI chrome exists, or without any at all. It lists the commands it was told + * about, and there is no way to make that list complete: a viewer embedded in + * another application, or driven from Python, can bind an action without ever + * registering a command for it. See `docs/concepts/commands.rst`. + */ + +import type { Command, CommandId } from "#src/ui/command.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { NullarySignal } from "#src/util/signal.js"; + +/** + * Per-viewer registry of {@link Command}s. Registration returns a disposer that + * unregisters the command, so feature code can add commands for the lifetime of + * a layer / control and clean up automatically. + */ +export class CommandRegistry extends RefCounted { + private readonly commands = new Map(); + private readonly commandChangedDisposers = new Map void>(); + + /** + * Dispatched when a command is registered or unregistered, or when a + * registered command reports a change of its own. + */ + readonly changed = new NullarySignal(); + + /** Registers `command`. Throws on duplicate id. Returns a disposer. */ + register(command: Command): () => void { + const { id } = command; + if (this.commands.has(id)) { + throw new Error(`Command already registered: ${JSON.stringify(id)}`); + } + this.commands.set(id, command); + this.commandChangedDisposers.set( + id, + command.changed.add(() => this.changed.dispatch()), + ); + this.changed.dispatch(); + return () => this.unregister(id); + } + + unregister(id: CommandId): void { + if (!this.commands.delete(id)) return; + const disposer = this.commandChangedDisposers.get(id); + if (disposer !== undefined) { + disposer(); + this.commandChangedDisposers.delete(id); + } + this.changed.dispatch(); + } + + get(id: CommandId): Command | undefined { + return this.commands.get(id); + } + + has(id: CommandId): boolean { + return this.commands.has(id); + } + + /** Iterates every registered command, enabled or not, in registration order. */ + values(): IterableIterator { + return this.commands.values(); + } + + disposed() { + for (const disposer of this.commandChangedDisposers.values()) disposer(); + this.commandChangedDisposers.clear(); + this.commands.clear(); + super.disposed(); + } +} diff --git a/src/ui/default_commands.spec.ts b/src/ui/default_commands.spec.ts new file mode 100644 index 0000000000..f474ff422b --- /dev/null +++ b/src/ui/default_commands.spec.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from "vitest"; +import { collectActionBindings } from "#src/ui/command_catalog.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; +import { + getDefaultCommands, + registerDefaultCommands, +} from "#src/ui/default_commands.js"; +import { + getDefaultGlobalBindings, + getDefaultPerspectivePanelBindings, + getDefaultSliceViewPanelBindings, +} from "#src/ui/default_input_event_bindings.js"; +import type { ActionIdentifier } from "#src/util/event_action_map.js"; +import { EventActionMap } from "#src/util/event_action_map.js"; +import type { InputEventBindings } from "#src/viewer.js"; + +// Actions the default commands deliberately do not declare: tool slots and +// per-layer-index actions, both of which the catalog contributes dynamically. +const DYNAMIC_ACTIONS = [ + /^tool-[A-Z]$/, + /^(toggle|select|toggle-pick)-layer-\d+$/, +]; + +// Commands that exist as viewer actions but have no default key binding. +const UNBOUND_BY_DEFAULT = new Set([ + "edit-json-state", + "screenshot", + "deactivate-active-tool", +]); + +function makeDefaultInputEventBindings(): InputEventBindings { + const make = (parent: EventActionMap) => { + const map = new EventActionMap(); + map.addParent(parent, Number.NEGATIVE_INFINITY); + return map; + }; + return { + global: make(getDefaultGlobalBindings()), + sliceView: make(getDefaultSliceViewPanelBindings()), + perspectiveView: make(getDefaultPerspectivePanelBindings()), + } as unknown as InputEventBindings; +} + +// Every keyboard-bound action reachable from the default global, slice-view and +// perspective-view bindings (the latter two pull in the shared rendered data +// panel bindings as a parent). +function defaultBoundActions(): ActionIdentifier[] { + return collectActionBindings(makeDefaultInputEventBindings()) + .map(({ actionId }) => actionId) + .filter((actionId) => !DYNAMIC_ACTIONS.some((re) => re.test(actionId))); +} + +function makeDefaultRegistry() { + const registry = new CommandRegistry(); + registerDefaultCommands(registry); + return registry; +} + +describe("registerDefaultCommands", () => { + it("declares a command for every default keyboard binding", () => { + const registry = makeDefaultRegistry(); + const missing = defaultBoundActions().filter( + (actionId) => !registry.has(actionId), + ); + expect(missing).toStrictEqual([]); + registry.dispose(); + }); + + it("declares no command id that is not a real action", () => { + // The reverse direction: a typo in a command id would otherwise register a + // command that dispatches an `action:` event nothing listens for. Every + // declared command must either be bound by default or be listed as + // knowingly unbound. + const bound = new Set(defaultBoundActions()); + const unaccounted = getDefaultCommands() + .map(({ id }) => id) + .filter((id) => !bound.has(id) && !UNBOUND_BY_DEFAULT.has(id)); + expect(unaccounted).toStrictEqual([]); + }); + + it("gives every command a label and a description", () => { + const registry = makeDefaultRegistry(); + for (const command of registry.values()) { + expect(command.label, command.id).not.toBe(""); + expect(command.description, command.id).toBeTruthy(); + } + registry.dispose(); + }); + + it("registers the axis commands for each of x, y and z", () => { + const registry = makeDefaultRegistry(); + for (const axis of ["x", "y", "z"]) { + for (const sign of ["-", "+"]) { + expect(registry.has(`${axis}${sign}`), `${axis}${sign}`).toBe(true); + expect( + registry.has(`rotate-relative-${axis}${sign}`), + `rotate-relative-${axis}${sign}`, + ).toBe(true); + } + } + registry.dispose(); + }); +}); diff --git a/src/ui/default_commands.ts b/src/ui/default_commands.ts new file mode 100644 index 0000000000..4ab38c2ecc --- /dev/null +++ b/src/ui/default_commands.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @file Declarations of the built-in viewer commands. + * + * Each entry names an existing DOM action (`id` === the `action:` id dispatched + * by the default input-event bindings) and gives it an explicit, human-readable + * label and help description, so that consumers no longer have to prettify an + * action id and can show commands that happen to have no binding at all. + * + * Commands whose behaviour is per-entity or otherwise dynamic (layer toggles, + * tool activation) are contributed by the catalog at enumeration time and are + * intentionally *not* declared here. + * + * This is the built-in *seed* set. It exists only because these commands + * correspond to DOM actions that predate the registry. Feature code should NOT + * add entries here; instead register commands colocated with the feature, for + * its own lifetime, e.g. + * + * this.registerDisposer( + * viewer.commandRegistry.register( + * new CallbackCommand("add-clip-plane", "Add Clip Plane", () => + * this.addPlane(), + * ), + * ), + * ); + * + * `register` returns a disposer, so commands may come and go with the feature + * (e.g. per-layer). + */ + +import { ActionCommand, type CommandId } from "#src/ui/command.js"; +import type { CommandRegistry } from "#src/ui/command_registry.js"; +import { AXES_NAMES } from "#src/util/geom.js"; + +interface BuiltinCommand { + readonly id: CommandId; + readonly label: string; + readonly description: string; +} + +// Directional position nudges and relative rotations, one pair per axis (arrow +// keys / , . and r / e / shift+arrow keys in the data panels). The ids are +// derived from the same AXES_NAMES that RenderedDataPanel derives its action +// listeners from, so the two cannot drift apart. +function axisCommands(): BuiltinCommand[] { + const commands: BuiltinCommand[] = []; + for (const axis of AXES_NAMES) { + const upper = axis.toUpperCase(); + commands.push( + { + id: `${axis}-`, + label: `Move −${upper}`, + description: `Move the view one step in the −${upper} direction.`, + }, + { + id: `${axis}+`, + label: `Move +${upper}`, + description: `Move the view one step in the +${upper} direction.`, + }, + { + id: `rotate-relative-${axis}-`, + label: `Rotate −${upper}`, + description: `Rotate the view a small amount about the ${upper} axis (negative direction).`, + }, + { + id: `rotate-relative-${axis}+`, + label: `Rotate +${upper}`, + description: `Rotate the view a small amount about the ${upper} axis (positive direction).`, + }, + ); + } + return commands; +} + +const STATIC_COMMANDS: readonly BuiltinCommand[] = [ + // View toggles. + { + id: "toggle-show-slices", + label: "Toggle Slices in 3D", + description: "Show or hide the cross-section slices in the 3D view.", + }, + { + id: "toggle-scale-bar", + label: "Toggle Scale Bar", + description: "Show or hide the scale bar overlay.", + }, + { + id: "toggle-axis-lines", + label: "Toggle Axis Lines", + description: "Show or hide the axis line indicators.", + }, + { + id: "toggle-orthographic-projection", + label: "Toggle Orthographic Projection", + description: + "Switch the 3D view between perspective and orthographic projection.", + }, + { + id: "toggle-default-annotations", + label: "Toggle Bounding Box", + description: "Show or hide the default bounding-box annotations.", + }, + { + id: "toggle-show-statistics", + label: "Toggle Statistics", + description: "Show or hide the rendering statistics panel.", + }, + { + id: "toggle-layout", + label: "Toggle Layout", + description: "Cycle the data panel layout.", + }, + { + id: "toggle-layout-alternative", + label: "Toggle Alternative Layout", + description: "Cycle the alternative data panel layout.", + }, + { + id: "help", + label: "Show Help", + description: "Open the keyboard and mouse bindings help panel.", + }, + // Navigation. + { + id: "snap", + label: "Snap to Axis", + description: + "Snap the view orientation to the nearest axis-aligned orientation.", + }, + { + id: "zoom-in", + label: "Zoom In", + description: "Zoom the view in.", + }, + { + id: "zoom-out", + label: "Zoom Out", + description: "Zoom the view out.", + }, + { + id: "depth-range-decrease", + label: "Decrease Depth Range", + description: "Decrease the visible depth range of the 3D projection.", + }, + { + id: "depth-range-increase", + label: "Increase Depth Range", + description: "Increase the visible depth range of the 3D projection.", + }, + { + id: "t-", + label: "Previous Timestep", + description: "Step backward one frame along the time axis.", + }, + { + id: "t+", + label: "Next Timestep", + description: "Step forward one frame along the time axis.", + }, + // Layers / segmentation. + { + id: "add-layer", + label: "Add Layer", + description: "Add a new layer to the viewer.", + }, + { + id: "recolor", + label: "Randomize Colors", + description: "Assign a new random color seed to segmentation layers.", + }, + { + id: "clear-segments", + label: "Clear Selected Segments", + description: "Deselect all currently selected segments.", + }, + // Annotation. + { + id: "finish-annotation", + label: "Finish Annotation", + description: "Complete the annotation currently being drawn.", + }, + { + id: "undo-annotation-step", + label: "Undo Annotation Step", + description: "Undo the last point added to the in-progress annotation.", + }, + // Actions with no default key binding; before the registry the palette had to + // special-case these to surface them at all. + { + id: "edit-json-state", + label: "Edit JSON State", + description: "Open an editor for the raw viewer JSON state.", + }, + { + id: "screenshot", + label: "Screenshot", + description: "Capture a screenshot of the current view.", + }, + { + id: "deactivate-active-tool", + label: "Deactivate Active Tool", + description: "Deactivate whichever tool is currently active.", + }, +]; + +/** The built-in commands, in the order they are registered. */ +export function getDefaultCommands(): readonly BuiltinCommand[] { + return [...STATIC_COMMANDS, ...axisCommands()]; +} + +/** + * Registers the built-in commands into `registry`. Called once during default + * viewer setup. The registry is owned (and disposed) by the viewer, so no + * disposers are returned here, and the commands live for the viewer's lifetime. + */ +export function registerDefaultCommands(registry: CommandRegistry): void { + for (const { id, label, description } of getDefaultCommands()) { + registry.register(new ActionCommand(id, label, description)); + } +} diff --git a/src/ui/default_viewer_setup.ts b/src/ui/default_viewer_setup.ts index 8adeb5e8b3..b00de52e3a 100644 --- a/src/ui/default_viewer_setup.ts +++ b/src/ui/default_viewer_setup.ts @@ -21,6 +21,7 @@ import { bindDefaultCopyHandler, bindDefaultPasteHandler, } from "#src/ui/default_clipboard_handling.js"; +import { registerDefaultCommands } from "#src/ui/default_commands.js"; import { setDefaultInputEventBindings } from "#src/ui/default_input_event_bindings.js"; import { makeDefaultViewer } from "#src/ui/default_viewer.js"; import type { MinimalViewerOptions } from "#src/ui/minimal_viewer.js"; @@ -64,6 +65,7 @@ export function setupDefaultViewer(options?: Partial) { bindDefaultCopyHandler(viewer); bindDefaultPasteHandler(viewer); + registerDefaultCommands(viewer.commandRegistry); const catalog = viewer.registerDisposer(new CommandCatalog(viewer)); bindCommandPalette(viewer, catalog); diff --git a/src/viewer.ts b/src/viewer.ts index ce9b115946..2f75fa1e2a 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -82,6 +82,7 @@ import { observeWatchable, TrackableValue, } from "#src/trackable_value.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; import { LayerArchiveCountWidget, LayerListPanel, @@ -1175,6 +1176,13 @@ export class Viewer extends RefCounted implements ViewerState { new GlobalToolBinder(this.toolInputEventMapBinder, this.toolPalettes), ); + // Binding-independent registry of the viewer's commands. Populated with the + // built-in commands during default viewer setup; feature code and embedding + // applications may register additional commands against it. It lists the + // commands it was told about, not every action that exists - see + // docs/concepts/commands.rst. + public commandRegistry = this.registerDisposer(new CommandRegistry()); + public toolBinder = this.registerDisposer( new LocalToolBinder(this, this.globalToolBinder), );