Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions docs/concepts/commands.rst
Original file line number Diff line number Diff line change
@@ -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:<id>`` 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┘
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Neuroglancer
:hidden:
:caption: Concepts

concepts/commands
concepts/coordinate_spaces
concepts/data_views
concepts/layers
Expand Down
125 changes: 125 additions & 0 deletions src/ui/command.ts
Original file line number Diff line number Diff line change
@@ -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:<id>` 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);
}
}
Loading