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/help/input_event_bindings.ts b/src/help/input_event_bindings.ts index ff5ec6b1b2..3fdcf41d0a 100644 --- a/src/help/input_event_bindings.ts +++ b/src/help/input_event_bindings.ts @@ -17,6 +17,7 @@ import "#src/help/input_event_bindings.css"; import type { LayerManager } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; +import { formatKeyStroke } from "#src/ui/command.js"; import type { SidePanelManager } from "#src/ui/side_panel.js"; import { SidePanel } from "#src/ui/side_panel.js"; import type { SidePanelLocation } from "#src/ui/side_panel_location.js"; @@ -37,24 +38,6 @@ declare let NEUROGLANCER_BUILD_INFO: | { tag: string; url?: string; timestamp?: string } | undefined; -export function formatKeyName(name: string) { - if (name.startsWith("key")) { - return name.substring(3); - } - if (name.startsWith("digit")) { - return name.substring(5); - } - if (name.startsWith("arrow")) { - return name.substring(5); - } - return name; -} - -export function formatKeyStroke(stroke: string) { - const parts = stroke.split("+"); - return parts.map(formatKeyName).join("+"); -} - const DEFAULT_HELP_PANEL_LOCATION: SidePanelLocation = { ...DEFAULT_SIDE_PANEL_LOCATION, side: "left", diff --git a/src/ui/command.ts b/src/ui/command.ts new file mode 100644 index 0000000000..6c0b3a1b08 --- /dev/null +++ b/src/ui/command.ts @@ -0,0 +1,143 @@ +/** + * @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); + } +} + +export function formatKeyName(name: string) { + if (name.startsWith("key")) { + return name.substring(3); + } + if (name.startsWith("digit")) { + return name.substring(5); + } + if (name.startsWith("arrow")) { + return name.substring(5); + } + return name; +} + +export function formatKeyStroke(stroke: string) { + const parts = stroke.split("+"); + return parts.map(formatKeyName).join("+"); +} diff --git a/src/ui/command_catalog.spec.ts b/src/ui/command_catalog.spec.ts new file mode 100644 index 0000000000..a24b66ec94 --- /dev/null +++ b/src/ui/command_catalog.spec.ts @@ -0,0 +1,273 @@ +/** + * @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 { 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"; + +function nextAnimationFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +function makeInputEventBindings( + global: EventActionMap, + sliceView = new EventActionMap(), + perspectiveView = new EventActionMap(), +): InputEventBindings { + return { + global, + sliceView, + perspectiveView, + } as unknown as InputEventBindings; +} + +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, + localBindersChanged: noopSignal, + bindings: new Map(), + localBinders: new Set(), + }, + toolBinder: { context: {} }, + layerManager: { + layersChanged: noopSignal, + managedLayers: [], + getLayerByName: () => undefined, + }, + selectedLayer: {}, + inputEventBindings, + commandRegistry, + } as unknown as CommandCatalogContext; +} + +describe("collectActionBindings", () => { + it("collects keyboard bindings", () => { + const map = new EventActionMap(); + map.set("keya", "some-action"); + const bindings = collectActionBindings(makeInputEventBindings(map)); + expect(bindings.map((binding) => binding.actionId)).toContain( + "some-action", + ); + }); + + it("excludes mouse and wheel events", () => { + const map = new EventActionMap(); + map.set("at:mousedown0", "mouse-action"); + map.set("at:wheel", "wheel-action"); + map.set("keya", "keyboard-action"); + const ids = collectActionBindings(makeInputEventBindings(map)).map( + (b) => b.actionId, + ); + expect(ids).toContain("keyboard-action"); + expect(ids).not.toContain("mouse-action"); + expect(ids).not.toContain("wheel-action"); + }); + + it("keeps only the first binding when an action appears in multiple maps", () => { + const globalMap = new EventActionMap(); + globalMap.set("keya", "shared-action"); + const sliceMap = new EventActionMap(); + sliceMap.set("keyb", "shared-action"); + const bindings = collectActionBindings( + makeInputEventBindings(globalMap, sliceMap), + ); + const forAction = bindings.filter((b) => b.actionId === "shared-action"); + expect(forAction).toHaveLength(1); + expect(forAction[0].eventAction.originalEventIdentifier).toBe("keya"); + }); + + it("excludes open-command-palette", () => { + const map = new EventActionMap(); + map.set("f1", "open-command-palette"); + map.set("keya", "some-action"); + const ids = collectActionBindings(makeInputEventBindings(map)).map( + (b) => b.actionId, + ); + expect(ids).not.toContain("open-command-palette"); + expect(ids).toContain("some-action"); + }); +}); + +describe("CommandCatalog.filter", () => { + // Seed the registry with two commands so the catalog surfaces exactly + // "Edit JSON State" and "Screenshot" as its flat entries. + function makeCatalog() { + 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", () => { + const catalog = makeCatalog(); + expect(catalog.filter("")).toStrictEqual(catalog.commands); + }); + + it("is case-insensitive", () => { + expect(makeCatalog().filter("EDIT")).toHaveLength(1); + expect(makeCatalog().filter("edit")).toHaveLength(1); + }); + + it("ranks prefix matches before substring matches", () => { + // "Screenshot" is a prefix match; "Edit JSON State" is a substring match. + // Verify all prefix matches appear before all substring matches. + const results = makeCatalog().filter("s"); + const screenshotIndex = results.findIndex((r) => r.label === "Screenshot"); + const stateIndex = results.findIndex((r) => r.label === "Edit JSON State"); + expect(screenshotIndex).toBeGreaterThanOrEqual(0); + expect(stateIndex).toBeGreaterThanOrEqual(0); + expect(screenshotIndex).toBeLessThan(stateIndex); + }); + + it("returns empty for a non-matching query", () => { + expect(makeCatalog().filter("xyz")).toHaveLength(0); + }); +}); + +describe("CommandCatalog command sources", () => { + function makeCatalog(map: EventActionMap, registry: CommandRegistry) { + return new CommandCatalog( + makeContext(makeInputEventBindings(map), registry), + ); + } + + 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 = catalog.commands.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"); + expect(entries[0].source).toBe("registered"); + } 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 = catalog.commands.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"); + expect(entries[0].source).toBe("derived"); + } 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( + catalog.commands.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( + catalog.commands.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(); + const context = makeContext(); + ( + context.layerManager as unknown as { layersChanged: Signal } + ).layersChanged = layersChanged; + const catalog = new CommandCatalog(context); + try { + let rebuildCount = 0; + catalog.changed.add(() => { + ++rebuildCount; + }); + layersChanged.dispatch(); + // The rebuild is debounced to an animation frame, so nothing fires yet. + expect(rebuildCount).toBe(0); + await nextAnimationFrame(); + expect(rebuildCount).toBe(1); + } finally { + catalog.dispose(); + } + }); +}); diff --git a/src/ui/command_catalog.ts b/src/ui/command_catalog.ts new file mode 100644 index 0000000000..ace6c3d5e4 --- /dev/null +++ b/src/ui/command_catalog.ts @@ -0,0 +1,456 @@ +/** + * @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 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, + CallbackCommand, + formatKeyStroke, +} from "#src/ui/command.js"; +import type { CommandRegistry } from "#src/ui/command_registry.js"; +import { + getMatchingTools, + restoreTool, + type GlobalToolBinder, + type LocalToolBinder, +} from "#src/ui/tool.js"; +import { parseToolQuery } from "#src/ui/tool_query.js"; +import type { DebouncedFunction } from "#src/util/animation_frame_debounce.js"; +import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; +import { RefCounted } from "#src/util/disposable.js"; +import type { + ActionIdentifier, + EventAction, + NormalizedEventIdentifier, +} from "#src/util/event_action_map.js"; +import { friendlyEventIdentifier } from "#src/util/event_action_map.js"; +import { Signal } from "#src/util/signal.js"; +import type { InputEventBindings } from "#src/viewer.js"; + +export interface CommandCatalogContext { + readonly globalToolBinder: GlobalToolBinder; + /** + * Tool context for tools that are not scoped to a layer. Tool factories are + * registered against a class prototype, so the binder's own context has to be + * a real instance of such a class rather than an arbitrary object. + */ + readonly toolBinder: LocalToolBinder; + readonly layerManager: LayerManager; + readonly selectedLayer: SelectedLayerState; + readonly 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. + */ + readonly commandRegistry: CommandRegistry; +} + +export interface ActionBinding { + readonly actionId: ActionIdentifier; + readonly eventAction: EventAction; +} + +export type CommandSource = "registered" | "derived"; + +// Can identify the sub-palette an entry belongs to. +export interface CommandGroup { + readonly label: string; + readonly shortcut: string; +} + +// 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 { + readonly shortcut: string; + readonly label: string; + readonly command: Command; + readonly source: CommandSource; + readonly group?: CommandGroup; +} + +// Fallback label for a bound action that no command was registered for. +function actionIdToLabel(actionId: ActionIdentifier): string { + return actionId + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function isKeyboardEvent(normalizedId: NormalizedEventIdentifier): boolean { + return ( + !normalizedId.includes("mouse") && + !normalizedId.includes("wheel") && + !normalizedId.includes("touch") && + !normalizedId.includes("click") + ); +} + +// Creates a Tool instance from a palette-form JSON object (with optional "layer" field). +// Caller is responsible for disposing the returned tool. +function createToolFromJson(context: CommandCatalogContext, toolJson: unknown) { + try { + const json = + typeof toolJson === "object" && toolJson !== null + ? (toolJson as Record) + : undefined; + const layerName = typeof json?.layer === "string" ? json.layer : undefined; + if (layerName !== undefined) { + const { layer: _ignored, ...rest } = json!; + const managedLayer = context.layerManager.getLayerByName(layerName); + const userLayer = managedLayer?.layer ?? null; + if (userLayer === null) return undefined; + return restoreTool(userLayer, rest); + } + return restoreTool(context.toolBinder.context, toolJson); + } catch { + return undefined; + } +} + +// Attemp for full description of tool by creating then disposing +function getToolDescription( + context: CommandCatalogContext, + toolJson: unknown, +): string { + const tool = createToolFromJson(context, toolJson); + if (tool === undefined) return toolJsonToLabel(toolJson); + const label = + tool.context instanceof UserLayer + ? `${tool.description} — ${tool.context.managedLayer.name}` + : tool.description; + tool.dispose(); + return label; +} + +// Fallback label derived purely from the JSON structure +function toolJsonToLabel(toolJson: unknown): string { + const json = + typeof toolJson === "object" && toolJson !== null + ? (toolJson as Record) + : undefined; + const typeName = + typeof toolJson === "string" + ? toolJson + : typeof json?.type === "string" + ? json.type + : undefined; + const layerName = typeof json?.layer === "string" ? json.layer : undefined; + const base = + typeName !== undefined + ? typeName + .replace(/([A-Z])/g, " $1") + .replace(/-./g, (s) => " " + s[1].toUpperCase()) + .replace(/^./, (s) => s.toUpperCase()) + .trim() + : "Unknown Tool"; + return layerName !== undefined ? `${base} — ${layerName}` : base; +} + +function isToolLayerVisible( + context: CommandCatalogContext, + toolJson: unknown, +): boolean { + const json = + typeof toolJson === "object" && toolJson !== null + ? (toolJson as Record) + : undefined; + const layerName = typeof json?.layer === "string" ? json.layer : undefined; + if (layerName === undefined) return true; + const managedLayer = context.layerManager.getLayerByName(layerName); + return managedLayer !== undefined && managedLayer.visible; +} + +function activateUnboundTool( + context: CommandCatalogContext, + toolJson: unknown, +): void { + const tool = createToolFromJson(context, toolJson); + if (tool === undefined) return; + + const existingKey = tool.localBinder.jsonToKey.get( + JSON.stringify(tool.toJSON()), + ); + if (existingKey !== undefined) { + tool.dispose(); + context.globalToolBinder.activate(existingKey); + } else { + context.globalToolBinder.activateDirect(tool); + } +} + +/** + * Walk the event action maps available on the viewer and produce a list of + * every action with any keyboard binding. The first binding found for each + * action is kept; subsequent bindings for the same action are ignored. + */ +export function collectActionBindings( + inputEventBindings: InputEventBindings, +): readonly ActionBinding[] { + const seenBindings = new Map(); + + const collect = ( + bindings: Iterable<[NormalizedEventIdentifier, EventAction]>, + ) => { + for (const [normalizedId, eventAction] of bindings) { + if (!isKeyboardEvent(normalizedId)) continue; + if (eventAction.action === "open-command-palette") continue; + if (!seenBindings.has(eventAction.action)) { + seenBindings.set(eventAction.action, eventAction); + } + } + }; + + collect(inputEventBindings.global.entries()); + collect(inputEventBindings.sliceView.entries()); + collect(inputEventBindings.perspectiveView.entries()); + + return Array.from(seenBindings.entries(), ([actionId, eventAction]) => ({ + actionId, + eventAction, + })); +} + +export class CommandCatalog extends RefCounted { + commands: readonly CommandEntry[] = []; + readonly changed = new Signal(); + private readonly debouncedRebuild: DebouncedFunction; + + constructor(private readonly context: CommandCatalogContext) { + super(); + const debouncedRebuild = (this.debouncedRebuild = this.registerCancellable( + animationFrameDebounce(() => this.rebuild()), + )); + this.registerDisposer( + context.globalToolBinder.changed.add(debouncedRebuild), + ); + this.registerDisposer( + context.globalToolBinder.localBindersChanged.add(debouncedRebuild), + ); + this.registerDisposer( + context.layerManager.layersChanged.add(debouncedRebuild), + ); + this.registerDisposer( + context.commandRegistry.changed.add(debouncedRebuild), + ); + this.rebuild(); + } + + public rebuild() { + const { + globalToolBinder, + layerManager, + selectedLayer, + inputEventBindings, + commandRegistry, + } = this.context; + const commands: CommandEntry[] = []; + + const layers = layerManager?.managedLayers ?? []; + + const toggleLayerGroup: CommandGroup = { + label: "Toggle Layer", + shortcut: "1–9", + }; + for (const [index, layer] of layers.entries()) { + commands.push({ + label: layer.name, + shortcut: index < 9 ? String(index + 1) : "", + source: "derived", + group: toggleLayerGroup, + command: new CallbackCommand( + `toggle-layer-${index + 1}`, + `Show/hide ${layer.name}`, + () => layer.setVisible(!layer.visible), + ), + }); + } + + const selectLayerGroup: CommandGroup = { + label: "Select Layer", + shortcut: "Ctrl+1–9", + }; + for (const [index, layer] of layers.entries()) { + commands.push({ + label: layer.name, + shortcut: index < 9 ? `Ctrl+${index + 1}` : "", + source: "derived", + group: selectLayerGroup, + command: new CallbackCommand( + `select-layer-${index + 1}`, + `Select ${layer.name}`, + () => { + selectedLayer.layer = layer; + selectedLayer.visible = true; + }, + ), + }); + } + + const togglePickLayerGroup: CommandGroup = { + label: "Toggle Pick Layer", + shortcut: "Alt+1–9", + }; + for (const [index, layer] of layers.entries()) { + commands.push({ + label: layer.name, + shortcut: index < 9 ? `Alt+${index + 1}` : "", + source: "derived", + group: togglePickLayerGroup, + command: new CallbackCommand( + `toggle-pick-layer-${index + 1}`, + `Toggle pick ${layer.name}`, + () => { + layer.pickEnabled = !layer.pickEnabled; + }, + ), + }); + } + + const bindings = collectActionBindings(inputEventBindings); + const shortcutByAction = new Map(); + for (const { actionId, eventAction } of bindings) { + shortcutByAction.set( + actionId, + formatKeyStroke( + friendlyEventIdentifier(eventAction.originalEventIdentifier ?? ""), + ), + ); + } + + // 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({ + label: command.label, + shortcut: shortcutByAction.get(command.id) ?? "", + source: "registered", + 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; + if (/^(toggle|select|toggle-pick)-layer-\d+$/.test(actionId)) continue; + const label = actionIdToLabel(actionId); + commands.push({ + label, + shortcut: shortcutByAction.get(actionId) ?? "", + source: "derived", + command: new ActionCommand(actionId, label), + }); + } + + const toolQueryResult = parseToolQuery("+"); + if ("query" in toolQueryResult) { + // Tool listers report changes to their available tool set (e.g. controls + // that appear once a data source resolves) via this callback + let toolSetChanged = false; + const onListableToolsChanged = () => { + if (toolSetChanged) return; + toolSetChanged = true; + this.debouncedRebuild(); + }; + const toolMatches = getMatchingTools( + globalToolBinder, + toolQueryResult.query, + onListableToolsChanged, + ); + + // Build a reverse lookup from palette-JSON key to letter for currently-bound tools. + // Keys must include getCommonToolProperties() to match the keys produced by + // getMatchingTools, which merges commonProperties into every yielded tool JSON. + const boundByJsonKey = new Map(); + for (const [letter, tool] of globalToolBinder.bindings) { + const paletteJson = { + ...tool.localBinder.convertLocalJSONToPaletteJSON(tool.toJSON()), + ...tool.localBinder.getCommonToolProperties(), + }; + boundByJsonKey.set(JSON.stringify(paletteJson), letter); + } + + for (const [jsonKey, toolJson] of toolMatches) { + if (!isToolLayerVisible(this.context, toolJson)) continue; + const boundLetter = boundByJsonKey.get(jsonKey); + if (boundLetter !== undefined) { + const actionId: ActionIdentifier = `tool-${boundLetter}`; + const tool = globalToolBinder.bindings.get(boundLetter)!; + const label = + tool.context instanceof UserLayer + ? `${tool.description} — ${tool.context.managedLayer.name}` + : tool.description; + commands.push({ + label, + shortcut: shortcutByAction.get(actionId) ?? "", + source: "derived", + command: new ActionCommand(actionId, label), + }); + } else { + const capturedToolJson = toolJson; + const label = getToolDescription(this.context, toolJson); + commands.push({ + label, + shortcut: "", + source: "derived", + command: new CallbackCommand(jsonKey, label, () => + activateUnboundTool(this.context, capturedToolJson), + ), + }); + } + } + } + + this.commands = commands; + this.changed.dispatch(); + } + + // Restricting to `groupLabel` scopes the search to a single group's entries + filter( + searchString: string, + groupLabel?: string, + ignoreGroupsForGlobalSearch: boolean = true, + ): readonly CommandEntry[] { + let pool = this.commands; + if (groupLabel !== undefined) { + pool = pool.filter((entry) => entry.group?.label === groupLabel); + } else if (ignoreGroupsForGlobalSearch) { + pool = pool.filter((entry) => entry.group === undefined); + } + + if (searchString === "") return pool; + + const query = searchString.toLowerCase(); + const prefixMatches: CommandEntry[] = []; + const substringMatches: CommandEntry[] = []; + + for (const command of pool) { + const label = command.label.toLowerCase(); + if (label.startsWith(query)) prefixMatches.push(command); + else if (label.includes(query)) substringMatches.push(command); + } + + return [...prefixMatches, ...substringMatches]; + } +} diff --git a/src/ui/command_palette.css b/src/ui/command_palette.css new file mode 100644 index 0000000000..8ca31464b0 --- /dev/null +++ b/src/ui/command_palette.css @@ -0,0 +1,80 @@ +/** + * @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. + */ + +.neuroglancer-command-palette.overlay-content { + background: #222; + color: #fff; + padding: 0; + width: 500px; + max-width: 90vw; + display: flex; + flex-direction: column; + max-height: 60vh; +} + +.neuroglancer-command-palette-input-row { + border-bottom: 1px solid #444; +} + +.neuroglancer-command-palette-input { + width: 100%; + background: transparent; + border: none; + outline: none; + color: inherit; + padding: 8px; + box-sizing: border-box; +} + +.neuroglancer-command-palette-results { + overflow-y: auto; +} + +.neuroglancer-command-palette-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 8px; + cursor: pointer; +} + +.neuroglancer-command-palette-row:hover, +.neuroglancer-command-palette-row[data-active] { + background: #444; +} + +.neuroglancer-command-palette-shortcut { + color: #aaa; + margin-left: 12px; + flex-shrink: 0; +} + +.neuroglancer-command-palette-empty { + padding: 8px; + color: #888; +} + +.neuroglancer-command-palette-picker-header { + padding: 4px 8px; + color: #aaa; + font-size: 12px; + cursor: pointer; + border-bottom: 1px solid #333; +} + +.neuroglancer-command-palette-picker-header:hover { + color: #fff; +} diff --git a/src/ui/command_palette.ts b/src/ui/command_palette.ts new file mode 100644 index 0000000000..e857ac76f4 --- /dev/null +++ b/src/ui/command_palette.ts @@ -0,0 +1,338 @@ +/** + * @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 "#src/ui/command_palette.css"; +import { Overlay } from "#src/overlay.js"; +import type { + CommandCatalog, + CommandEntry, + CommandGroup, +} from "#src/ui/command_catalog.js"; +import type { Viewer } from "#src/viewer.js"; + +type PaletteRow = + | { readonly kind: "command"; readonly entry: CommandEntry } + | { readonly kind: "group-header"; readonly group: CommandGroup }; + +export class CommandPalette extends Overlay { + private readonly searchInput: HTMLInputElement; + private readonly resultsList: HTMLElement; + private readonly rowElementByKey = new Map< + CommandEntry | CommandGroup, + HTMLElement + >(); + private readonly emptyElement: HTMLElement; + private readonly pickerHeaderElement: HTMLElement; + private filteredRows: readonly PaletteRow[] = []; + private filteredRowElements: HTMLElement[] = []; + private activeIndex = 0; + private currentGroup: CommandGroup | undefined; + + private readonly keyHandlers: Partial< + Record void> + > = { + ArrowDown: (event) => { + event.preventDefault(); + this.setActive(this.activeIndex + 1); + }, + ArrowUp: (event) => { + event.preventDefault(); + this.setActive(this.activeIndex - 1); + }, + Enter: (event) => { + event.preventDefault(); + event.stopPropagation(); + if (this.filteredRows.length > 0) + this.run(this.filteredRows[this.activeIndex]); + }, + Backspace: () => { + if (this.currentGroup !== undefined && this.searchInput.value === "") { + this.goBack(); + } + }, + ArrowLeft: (event) => { + if ( + this.currentGroup !== undefined && + this.searchInput.selectionStart === 0 && + this.searchInput.selectionEnd === 0 + ) { + event.preventDefault(); + this.goBack(); + } + }, + Escape: () => { + if (this.currentGroup !== undefined) { + this.goBack(); + } else { + this.closeAndRestoreFocus(); + } + }, + }; + + constructor( + private readonly catalog: CommandCatalog, + private readonly actionDispatchTarget: HTMLElement, + ) { + super(); + this.content.classList.add("neuroglancer-command-palette"); + + const pickerHeader = (this.pickerHeaderElement = + document.createElement("div")); + pickerHeader.className = "neuroglancer-command-palette-picker-header"; + pickerHeader.setAttribute("hidden", ""); + pickerHeader.addEventListener("click", () => this.goBack()); + + const emptyElement = (this.emptyElement = document.createElement("div")); + emptyElement.className = "neuroglancer-command-palette-empty"; + emptyElement.textContent = "No commands found."; + + const inputContainer = document.createElement("div"); + inputContainer.className = "neuroglancer-command-palette-input-row"; + inputContainer.appendChild(pickerHeader); + const searchInput = (this.searchInput = document.createElement("input")); + searchInput.type = "text"; + searchInput.className = "neuroglancer-command-palette-input"; + searchInput.placeholder = "Type a command..."; + searchInput.autocomplete = "off"; + searchInput.spellcheck = false; + inputContainer.appendChild(searchInput); + this.content.appendChild(inputContainer); + + const resultsList = (this.resultsList = document.createElement("div")); + resultsList.className = "neuroglancer-command-palette-results"; + this.content.appendChild(resultsList); + + catalog.rebuild(); + this.buildRows(); + + searchInput.addEventListener("input", () => { + this.activeIndex = 0; + this.render(); + }); + + resultsList.addEventListener("mousedown", (event) => + event.preventDefault(), + ); + + this.content.addEventListener( + "keydown", + (event: KeyboardEvent) => this.keyHandlers[event.key]?.(event), + { capture: true }, + ); + + // Tools register keydown on window (bubble); stop propagation here after searchInput receives the event. + this.content.addEventListener("keydown", (event) => { + event.stopPropagation(); + }); + + // The catalog may rebuild while this palette is open (a layer or tool + // change, or an async lister resolving). Build rows for the new entries + // and re-render, since the current view (grouped or not) is always + // derived live from the catalog rather than a snapshot. + this.registerDisposer( + this.catalog.changed.add(() => { + this.buildRows(); + this.render(); + }), + ); + + this.render(); + searchInput.focus(); + } + + private buildRows() { + for (const entry of this.catalog.commands) { + if (!this.rowElementByKey.has(entry)) { + this.rowElementByKey.set( + entry, + this.createRowElement(entry.label, entry.shortcut, () => + this.run({ kind: "command", entry }), + ), + ); + } + const { group } = entry; + if (group !== undefined && !this.rowElementByKey.has(group)) { + this.rowElementByKey.set( + group, + this.createRowElement(group.label, group.shortcut, () => + this.run({ kind: "group-header", group }), + ), + ); + } + } + } + + private createRowElement( + label: string, + shortcut: string, + onActivate: () => void, + ): HTMLElement { + const rowElement = document.createElement("div"); + rowElement.className = "neuroglancer-command-palette-row"; + rowElement.addEventListener("click", onActivate); + + const labelElement = document.createElement("span"); + labelElement.textContent = label; + rowElement.appendChild(labelElement); + + if (shortcut) { + const shortcutElement = document.createElement("span"); + shortcutElement.className = "neuroglancer-command-palette-shortcut"; + shortcutElement.textContent = shortcut; + rowElement.appendChild(shortcutElement); + } + + return rowElement; + } + + // Inside a group, or while searching, the view is a flat list of matching + // commands. Otherwise, entries sharing a group collapse into one header row. + private computeDisplayRows(): readonly PaletteRow[] { + const searchValue = this.searchInput.value; + if (this.currentGroup !== undefined) { + return this.catalog + .filter(searchValue, this.currentGroup.label) + .map((entry) => ({ kind: "command", entry }) as const); + } + if (searchValue !== "") { + return this.catalog + .filter(searchValue) + .map((entry) => ({ kind: "command", entry }) as const); + } + const rows: PaletteRow[] = []; + const seenGroups = new Set(); + for (const entry of this.catalog.commands) { + const { group } = entry; + if (group === undefined) { + rows.push({ kind: "command", entry }); + } else if (!seenGroups.has(group.label)) { + seenGroups.add(group.label); + rows.push({ kind: "group-header", group }); + } + } + return rows; + } + + private rowElementFor(row: PaletteRow): HTMLElement { + return this.rowElementByKey.get( + row.kind === "command" ? row.entry : row.group, + )!; + } + + private render() { + this.filteredRows = this.computeDisplayRows(); + if (this.activeIndex >= this.filteredRows.length) { + this.activeIndex = Math.max(0, this.filteredRows.length - 1); + } + + if (this.filteredRows.length === 0) { + this.resultsList.replaceChildren(this.emptyElement); + return; + } + + this.filteredRowElements = this.filteredRows.map((row) => + this.rowElementFor(row), + ); + this.filteredRowElements.forEach((rowElement, rowIndex) => { + rowElement.toggleAttribute("data-active", rowIndex === this.activeIndex); + }); + this.resultsList.replaceChildren(...this.filteredRowElements); + } + + private setActive(targetIndex: number) { + if (this.filteredRowElements.length === 0) return; + this.activeIndex = + ((targetIndex % this.filteredRowElements.length) + + this.filteredRowElements.length) % + this.filteredRowElements.length; + this.filteredRowElements.forEach((rowElement, rowIndex) => { + rowElement.toggleAttribute("data-active", rowIndex === this.activeIndex); + if (rowIndex === this.activeIndex) + rowElement.scrollIntoView({ block: "nearest" }); + }); + } + + private updateHeader() { + if (this.currentGroup !== undefined) { + this.pickerHeaderElement.textContent = `← ${this.currentGroup.label}`; + this.pickerHeaderElement.removeAttribute("hidden"); + } else { + this.pickerHeaderElement.setAttribute("hidden", ""); + } + } + + private goBack() { + if (this.currentGroup === undefined) { + this.closeAndRestoreFocus(); + return; + } + this.currentGroup = undefined; + this.searchInput.value = ""; + this.searchInput.placeholder = "Type a command..."; + this.updateHeader(); + this.activeIndex = 0; + this.render(); + } + + // Non-toggle tools register a window bubble-phase keydown handler that + // calls preventDefault() on all keys. Restoring focus to the viewer element + // before the next keydown ensures F1 bubbles through the viewer's + // KeyboardEventBinder and can reopen the palette. + private closeAndRestoreFocus() { + const target = this.actionDispatchTarget; + this.close(); + target.focus({ preventScroll: true }); + } + + private run(row: PaletteRow) { + if (row.kind === "group-header") { + this.currentGroup = row.group; + this.searchInput.value = ""; + this.searchInput.placeholder = `Filter ${row.group.label}…`; + this.updateHeader(); + this.activeIndex = 0; + this.render(); + return; + } + + this.closeAndRestoreFocus(); + row.entry.command.invoke({ dispatchTarget: this.actionDispatchTarget }); + } +} + +/** + * Binds the command palette to a viewer by handling the "open-command-palette" + * action at the viewer element level, the same way every other global action + * (e.g. "help") is bound. This intentionally + * does not install any document-level key listener, so the palette opens from + * the main viewer UI but does not intercept keystrokes globally. + */ +export function bindCommandPalette(viewer: Viewer): void { + let openPalette: CommandPalette | undefined; + const openCommandPalette = () => { + if (openPalette !== undefined && !openPalette.wasDisposed) return; + const prevFocused = document.activeElement; + // Tracking the dispatch target lets an activated command target the + // specific element that had focus (e.g. the "snap" action in the panel the + // user was in), falling back to the viewer element. + const dispatchTarget = + prevFocused instanceof HTMLElement && viewer.element.contains(prevFocused) + ? prevFocused + : viewer.element; + openPalette = new CommandPalette(viewer.commandCatalog, dispatchTarget); + }; + viewer.bindAction("open-command-palette", openCommandPalette); +} 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_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index c041bc0335..88f49af78b 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -48,6 +48,8 @@ export function getDefaultGlobalBindings() { map.set("space", "toggle-layout"); map.set("shift+space", "toggle-layout-alternative"); map.set("backslash", "toggle-show-statistics"); + map.set("control+keyp", "open-command-palette"); + map.set("escape", "deactivate-active-tool"); defaultGlobalBindings = map; } return defaultGlobalBindings; diff --git a/src/ui/default_viewer_setup.ts b/src/ui/default_viewer_setup.ts index fc6bb1a406..f42d9cbaaa 100644 --- a/src/ui/default_viewer_setup.ts +++ b/src/ui/default_viewer_setup.ts @@ -15,10 +15,12 @@ */ import { StatusMessage } from "#src/status.js"; +import { bindCommandPalette } from "#src/ui/command_palette.js"; 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"; @@ -62,6 +64,8 @@ export function setupDefaultViewer(options?: Partial) { bindDefaultCopyHandler(viewer); bindDefaultPasteHandler(viewer); + registerDefaultCommands(viewer.commandRegistry); + bindCommandPalette(viewer); return viewer; } diff --git a/src/ui/tool.ts b/src/ui/tool.ts index 847bfe877b..03f870b683 100644 --- a/src/ui/tool.ts +++ b/src/ui/tool.ts @@ -491,6 +491,20 @@ export class GlobalToolBinder extends RefCounted { public deactivate() { this.debounceDeactivate(); } + + // Activate a tool that has no letter-key binding. The tool is treated as + // toggle-mode (stays active until explicitly deactivated). The ToolActivation + // takes ownership of the tool via registerDisposer so the tool is disposed + // automatically when the activation ends. + activateDirect(tool: Owned): void { + this.queuedTool = undefined; // explicit activation clears any queued toggle tool + this.deactivate_(); // cancels debounce + disposes current activation + const activation = new ToolActivation(tool, this.inputEventMapBinder); + activation.registerDisposer(tool); + this.activeTool_ = activation; + tool.activate(activation); + this.changed.dispatch(); + } } export class LocalToolBinder< diff --git a/src/util/event_action_map.ts b/src/util/event_action_map.ts index bddf12a6f0..912c7074d9 100644 --- a/src/util/event_action_map.ts +++ b/src/util/event_action_map.ts @@ -475,7 +475,7 @@ export function dispatchEventWithModifiers( /** * DOM Event type used for dispatching actions. * - * Additional information relevant to the acction is specified as the `detail` property. + * Additional information relevant to the action is specified as the `detail` property. */ export interface ActionEvent extends CustomEvent { detail: Info; diff --git a/src/viewer.ts b/src/viewer.ts index 4b171fdf06..b243281408 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -82,6 +82,8 @@ import { observeWatchable, TrackableValue, } from "#src/trackable_value.js"; +import { CommandCatalog } from "#src/ui/command_catalog.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; import { LayerArchiveCountWidget, LayerListPanel, @@ -1138,6 +1140,12 @@ export class Viewer extends RefCounted implements ViewerState { this.showPerspectiveSliceViews.toggle(), ); this.bindAction("toggle-show-statistics", () => this.showStatistics()); + + this.bindAction("deactivate-active-tool", () => + this.globalToolBinder.deactivate(), + ); + this.bindAction("edit-json-state", () => this.editJsonState()); + this.bindAction("screenshot", () => this.showScreenshotDialog()); } toggleHelpPanel() { @@ -1169,6 +1177,20 @@ 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()); + private commandCatalog_: CommandCatalog | undefined; + + get commandCatalog(): CommandCatalog { + return (this.commandCatalog_ ??= this.registerDisposer( + new CommandCatalog(this), + )); + } + public toolBinder = this.registerDisposer( new LocalToolBinder(this, this.globalToolBinder), );