Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
758970a
feat: add command palette
seankmartin Jun 6, 2026
f18b147
fix: stop tools eating command palette inputs
seankmartin Jun 8, 2026
0722c90
feat: allow unbound tools to activate and doc level command
seankmartin Jun 8, 2026
ecea5e3
test: update tests
seankmartin Jun 8, 2026
91ad775
feat: don't show tools for hidden layers
seankmartin Jun 8, 2026
6950aa0
feat: add heirarchy to command palette
seankmartin Jun 8, 2026
7d68aa4
fix: correct global bind key
seankmartin Jun 8, 2026
9fa5c4a
fix: correct tool binding and correct pattern
seankmartin Jun 22, 2026
aae0de6
feat: bind to non-key tool
seankmartin Jun 22, 2026
a565247
fix: show user bind
seankmartin Jun 22, 2026
d5c8740
feat: add more reliable rebuild and command type identification
seankmartin Jul 8, 2026
16aa7ed
Merge branch 'master' into feat/command-palette
seankmartin Jul 13, 2026
4ba9bd2
refactor: split CommandCatalog into a DOM-free command_catalog module
Le0C Jul 13, 2026
de134f4
Merge pull request #216 from Le0C/split-command-catalog
seankmartin Jul 16, 2026
d923d18
feat: Add CommandRegistry and default command descriptions.
Le0C Jul 20, 2026
312ea90
refactor: drop category and defaultBinding from CommandInfo
Le0C Aug 14, 2026
9760c2e
refactor: derive the axis command ids from AXES_NAMES
Le0C Aug 14, 2026
dcbffee
refactor: replace CommandInfo with a Command class
Le0C Aug 14, 2026
612106a
feat: list keyboard-bound actions that have no registered command
Le0C Aug 14, 2026
99ef88c
test: check the default commands against the default bindings
Le0C Aug 14, 2026
9497ae9
docs: add a commands concept page
Le0C Aug 14, 2026
311fdb6
Merge pull request #217 from Le0C/feat/command-registry
seankmartin Aug 14, 2026
f997b25
refactor: change command catalog to be owned by viewer
seankmartin Aug 16, 2026
1162891
fix: remove unused CommandCatalog import
Le0C Aug 17, 2026
5aed4e9
Merge pull request #226 from Le0C/fix/unused-command-catalog-import
seankmartin Aug 17, 2026
7b87129
refactor: extract formatKeyStroke to common
seankmartin Aug 17, 2026
2dec258
refactor: remove execute types in command catalog
seankmartin Aug 17, 2026
4157c65
refactor: remove command group kind
seankmartin Aug 17, 2026
79dda43
feat: give layer command labels more info
seankmartin Aug 17, 2026
edce418
refactor: make context requirement for tools explicit
seankmartin Aug 17, 2026
0ff24df
test: small extra test on source
seankmartin Aug 17, 2026
5ff58f6
feat: add bind for deactivate tool
seankmartin Aug 17, 2026
b2e3415
chore: lint format
seankmartin Aug 18, 2026
05d10d4
feat: always rebuild command catalog when palette opens
seankmartin Aug 18, 2026
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
19 changes: 1 addition & 18 deletions src/help/input_event_bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down
143 changes: 143 additions & 0 deletions src/ui/command.ts
Original file line number Diff line number Diff line change
@@ -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:<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);
}
}

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("+");
}
Loading