Skip to content
Open
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
24 changes: 20 additions & 4 deletions src/ui/command_catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
* limitations under the License.
*/

import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
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";
Expand All @@ -42,9 +43,18 @@ function makeInputEventBindings(

const noopSignal = { add: () => () => {} };

// Registries created by makeContext, disposed after each test.
const activeRegistries: CommandRegistry[] = [];

afterEach(() => {
while (activeRegistries.length > 0) activeRegistries.pop()!.dispose();
});

function makeContext(
inputEventBindings = makeInputEventBindings(new EventActionMap()),
commandRegistry = new CommandRegistry(),
): CommandCatalogContext {
activeRegistries.push(commandRegistry);
return {
globalToolBinder: {
changed: noopSignal,
Expand All @@ -59,6 +69,7 @@ function makeContext(
},
selectedLayer: {},
inputEventBindings,
commandRegistry,
} as unknown as CommandCatalogContext;
}

Expand Down Expand Up @@ -111,10 +122,15 @@ describe("collectActionBindings", () => {
});

describe("CommandCatalog.filter", () => {
// With empty bindings the catalog contains only the two supplemental commands:
// "Edit JSON State" and "Screenshot".
// Seed the registry with two commands so the catalog surfaces exactly
// "Edit JSON State" and "Screenshot" as its flat entries.
function makeCatalog() {
return new CommandCatalog(makeContext());
const registry = new CommandRegistry();
registry.registerAction({ id: "edit-json-state", label: "Edit JSON State" });
registry.registerAction({ id: "screenshot", label: "Screenshot" });
return new CommandCatalog(
makeContext(makeInputEventBindings(new EventActionMap()), registry),
);
}

it("returns all commands for an empty query", () => {
Expand Down
98 changes: 60 additions & 38 deletions src/ui/command_catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import type { LayerManager, SelectedLayerState } from "#src/layer/index.js";
import { UserLayer } from "#src/layer/index.js";
import type { CommandRegistry } from "#src/ui/command_registry.js";
import {
getMatchingTools,
restoreTool,
Expand All @@ -39,16 +40,14 @@ export interface CommandCatalogContext {
layerManager: LayerManager;
selectedLayer: SelectedLayerState;
inputEventBindings: InputEventBindings;
/**
* Authoritative source of the flat command set. Its command-kind entries are
* enumerated directly; the input bindings are consulted only to annotate each
* command with its current shortcut, not to discover which commands exist.
*/
commandRegistry: CommandRegistry;
}

const SUPPLEMENTAL_COMMANDS: readonly {
actionId: ActionIdentifier;
label: string;
}[] = [
{ actionId: "edit-json-state", label: "Edit JSON State" },
{ actionId: "screenshot", label: "Screenshot" },
];

export interface ActionBinding {
readonly actionId: ActionIdentifier;
readonly eventAction: EventAction;
Expand All @@ -57,6 +56,8 @@ export interface ActionBinding {
interface CommandPaletteEntryBase {
readonly label: string;
readonly shortcut: string;
/** Optional grouping section, carried through from a registered command. */
readonly category?: string;
}

// Dispatched as an `action:<actionId>` DOM event, exactly as the keyboard
Expand All @@ -66,7 +67,16 @@ export interface ActionCommandEntry extends CommandPaletteEntryBase {
readonly actionId: ActionIdentifier;
}

// Runs a callback directly (no DOM action exists for it).
// A registered command that runs a callback. Unlike `execute`, it carries the
// registry's stable `id` so consumers can correlate it back to the registry.
export interface CommandEntry extends CommandPaletteEntryBase {
readonly kind: "command";
readonly id: ActionIdentifier;
readonly invoke: () => void;
}

// Runs an anonymous callback directly (no DOM action and no registry identity —
// e.g. a per-layer toggle or an unbound tool activation).
export interface ExecuteCommandEntry extends CommandPaletteEntryBase {
readonly kind: "execute";
readonly execute: () => void;
Expand All @@ -80,6 +90,7 @@ export interface GroupCommandEntry extends CommandPaletteEntryBase {

export type CommandPaletteEntry =
| ActionCommandEntry
| CommandEntry
| ExecuteCommandEntry
| GroupCommandEntry;

Expand All @@ -95,13 +106,6 @@ function formatKeyStroke(stroke: string): string {
.join("+");
}

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") &&
Expand Down Expand Up @@ -270,6 +274,9 @@ export class CommandCatalog extends RefCounted {
this.registerDisposer(
context.layerManager.layersChanged.add(debouncedRebuild),
);
this.registerDisposer(
context.commandRegistry.changed.add(debouncedRebuild),
);
this.rebuild();
}

Expand All @@ -279,17 +286,10 @@ export class CommandCatalog extends RefCounted {
layerManager,
selectedLayer,
inputEventBindings,
commandRegistry,
} = this.context;
const commands: CommandPaletteEntry[] = [];

// "Deactivate Active Tool" is always present — harmless no-op when nothing is active.
commands.push({
kind: "action",
label: "Deactivate Active Tool",
shortcut: "",
actionId: "deactivate-active-tool",
});

// Hierarchical layer actions — each group entry opens a sub-palette of layers.
// The first 9 layers carry their digit-key shortcuts so users can see they
// still work directly from the keyboard without opening the sub-palette.
Expand Down Expand Up @@ -347,20 +347,42 @@ export class CommandCatalog extends RefCounted {
);
}

for (const { actionId, eventAction } of bindings) {
if (/^tool-[A-Z]$/.test(actionId)) continue;
// Layer-index actions are replaced by hierarchical group entries above.
if (/^(toggle|select|toggle-pick)-layer-\d+$/.test(actionId)) continue;

const label = actionIdToLabel(actionId);
const shortcut = formatKeyStroke(
friendlyEventIdentifier(eventAction.originalEventIdentifier ?? ""),
);
commands.push({ kind: "action", label, shortcut, actionId });
}

for (const { actionId, label } of SUPPLEMENTAL_COMMANDS) {
commands.push({ kind: "action", label, shortcut: "", actionId });
// Flat commands come from the registry. A command's shortcut is whatever
// binding is currently installed for its id (or its suggested default),
// shown for reference only.
for (const command of commandRegistry.values()) {
if (command.isAvailable !== undefined && !command.isAvailable.value) {
continue;
}
const shortcut =
shortcutByAction.get(command.id) ??
(command.defaultBinding !== undefined
? formatKeyStroke(friendlyEventIdentifier(command.defaultBinding))
: "");
const { label, category } = command;
switch (command.type) {
case "action":
commands.push({
kind: "action",
label,
shortcut,
category,
actionId: command.id,
});
break;
case "callback": {
const invoke = command.invoke;
commands.push({
kind: "command",
label,
shortcut,
category,
id: command.id,
invoke: () => invoke(),
});
break;
}
}
}

const toolQueryResult = parseToolQuery("+");
Expand Down
2 changes: 2 additions & 0 deletions src/ui/command_palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ export class CommandPalette extends Overlay {

if (command.kind === "execute") {
command.execute();
} else if (command.kind === "command") {
command.invoke();
} else if (command.kind === "action") {
this.actionDispatchTarget.dispatchEvent(
new CustomEvent(`action:${command.actionId}`, {
Expand Down
122 changes: 122 additions & 0 deletions src/ui/command_registry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @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 { CommandRegistry } from "#src/ui/command_registry.js";
import { WatchableValue } from "#src/trackable_value.js";

describe("CommandRegistry", () => {
it("registers and retrieves a command by id", () => {
const registry = new CommandRegistry();
registry.registerAction({
id: "screenshot",
label: "Screenshot",
description: "Capture a screenshot.",
});
expect(registry.has("screenshot")).toBe(true);
expect(registry.get("screenshot")?.label).toBe("Screenshot");
registry.dispose();
});

it("stamps the command type explicitly per registrar", () => {
const registry = new CommandRegistry();
registry.registerAction({ id: "a", label: "A" });
registry.registerCallback({ id: "c", label: "C", invoke: () => {} });
expect(registry.get("a")?.type).toBe("action");
expect(registry.get("c")?.type).toBe("callback");
registry.dispose();
});

it("invokes a callback command's callback", () => {
const registry = new CommandRegistry();
let ran = false;
registry.registerCallback({
id: "c",
label: "C",
invoke: () => {
ran = true;
},
});
const command = registry.get("c");
if (command?.type === "callback") command.invoke();
expect(ran).toBe(true);
registry.dispose();
});

it("enumerates commands independent of any binding", () => {
const registry = new CommandRegistry();
registry.registerAction({ id: "a", label: "A" });
registry.registerAction({ id: "b", label: "B" });
expect([...registry.values()].map((c) => c.id)).toStrictEqual(["a", "b"]);
registry.dispose();
});

it("throws on duplicate id", () => {
const registry = new CommandRegistry();
registry.registerAction({ id: "dup", label: "First" });
expect(() =>
registry.registerAction({ id: "dup", label: "Second" }),
).toThrow(/already registered/);
registry.dispose();
});

it("unregisters via the returned disposer", () => {
const registry = new CommandRegistry();
const dispose = registry.registerAction({ id: "temp", label: "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.registerAction({ id: "x", label: "X" });
expect(count).toBe(1);
dispose();
expect(count).toBe(2);
registry.dispose();
});

it("dispatches changed when a command's availability changes", () => {
const registry = new CommandRegistry();
const isAvailable = new WatchableValue(true);
registry.registerAction({ id: "x", label: "X", isAvailable });
let count = 0;
registry.changed.add(() => ++count);
isAvailable.value = false;
expect(count).toBe(1);
registry.dispose();
});

it("stops observing availability after unregister", () => {
const registry = new CommandRegistry();
const isAvailable = new WatchableValue(true);
const dispose = registry.registerAction({
id: "x",
label: "X",
isAvailable,
});
dispose();
let count = 0;
registry.changed.add(() => ++count);
isAvailable.value = false;
expect(count).toBe(0);
registry.dispose();
});
});
Loading