graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(commands): one registry, so a command can be found, bound and run
Three vocabularies with nothing holding them together: the Action enum
matched against chords, the menu-item LABELS execute_menu_action
dispatches on, and a hand-written block listing which Action got which
chord. A command lived in whichever of them someone had needed at the
time, and nothing could tell you which commands had no binding at all.
src/command.rs is one list: id, label, context, how to run it, default
chord. ShortcutManager binds command IDS rather than Actions — which is
also what lets a chord reach a menu-dispatched command like Open, which
no binding could do before. State::run_command(id) is the single entry
point and is exposed over MCP, so every command is scriptable, including
the ones no menu label reaches.
The palette is the node palette's cce-cloud --dmenu popup, not a new
widget: two pickers in one app that look and behave differently is worse
than either. Ranking reproduces the plugin's fuzzyfinder exactly —
shortest contiguous span, then earliest start, then alphabetical — so
muscle memory survives the move, and the focused pane's commands
partition to the front without anything being hidden. Rows carry their
chord in a padded column (a tab renders as one literal stop and came out
ragged), so the palette teaches the keyboard rather than replacing it.
The registry paid for itself twice on the way in:
- The toolkit's runner already claims ctrl+z, ctrl+shift+z, ctrl+tab and
ctrl+shift+tab from input.kdl's cce-ui domain before the app sees them.
Undo and Redo therefore ship with NO chord here on purpose — the runner
routes them to the focused widget first, so a text box undoes its own
typing, and registering ctrl+z would have taken that away while looking
like a fix for a missing binding.
- Shortcut's derived PartialEq compared character keys byte for byte
while matches() compared them case-insensitively. Ctrl+S and Ctrl+s are
one keypress at the keyboard and were two values in memory, so the new
conflict detector quietly failed to report exactly the collision it
exists to catch. Both go through one same_key now.
The hotkey file this phase called for is already built and better than
proposed: input.kdl is workspace-wide with per-app domains, so a chord is
cce-designer.<id> and the registry supplies the default. What was missing
was not a file but a set of NAMES to put in it, plus conflict reporting.
A test scans execute_menu_action's arms so a command cannot name a label
that is not dispatched. Scanning source is an odd way to assert it, but
the alternative is calling every command to see whether it is handled,
and "Exit" would end the test run.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 64 ++++++++++++++
shapeshifter.md | 33 +++++++
src/api.rs | 9 ++
src/app.rs | 168 ++++++++++++++++++++++++++++-------
src/command.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++-----
src/shortcut.rs | 107 ++++++++++++++++++----
src/window.rs | 17 +++-
8 files changed, 827 insertions(+), 74 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9c5505d..b6ae5c3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -418,6 +418,70 @@ The GPU image is owned by `State::page_image` and freed when replaced;
everything-at-once node, is deliberately not ported: it is these four chained,
and that collapse is the whole premise of "fifty operators, ten nodes".
+### Commands, chords and the palette
+
+`src/command.rs` is one list of everything the app can be asked to do. Each row
+carries its `id` (snake_case — this is what `input.kdl` binds, so it follows
+that file's existing convention and must not change when the label does), its
+`label` (what the palette and menus show), a `Context` (which pane it belongs
+to), a `Run` (how it reaches the work), and a `default_chord`.
+
+Before it there were three vocabularies with nothing holding them together: the
+`Action` enum matched against chords, the menu-item LABELS `execute_menu_action`
+dispatches on, and a hand-written registration block listing which `Action` got
+which chord. A command lived in whichever of them someone had needed, and
+nothing could tell you which ones had no binding at all. `ShortcutManager` now
+binds **command ids**, not `Action`s — which is also what lets a chord reach a
+menu-dispatched command like Open, something no binding could do before.
+
+`Run` has two variants because the app genuinely has two dispatch paths; a
+command names exactly one, so the palette, the chord and the menu all end up in
+the same code. `State::run_command(id)` is the single entry point, and it is
+exposed over MCP as `run_command` — every command is scriptable, including the
+ones no menu label reaches.
+
+**The toolkit's runner claims four chords before the app sees them**, from
+`input.kdl`'s `cce-ui` domain: `undo` (ctrl+z), `redo` (ctrl+shift+z),
+`focus_next_group` (ctrl+tab) and `focus_prev_group` (ctrl+shift+tab). Undo and
+Redo are therefore registry rows with NO default chord — not an oversight: the
+runner routes them to the focused widget first, so a text box undoes its own
+typing before the app is asked, and registering ctrl+z here would quietly take
+that away. Focus Next/Previous Pane do override the runner's group chords, which
+is deliberate and predates the registry. `command::conflicts` cannot see any of
+this — it compares this app's bindings with each other — so it is written down
+here instead.
+
+`conflicts()` reports two commands resolving to one chord at startup, because
+the failure is otherwise silent and looks like a broken command rather than a
+broken binding: `match_command` returns the first match and the second simply
+never runs. It compares chords as PARSED, not as text. That exposed a real bug:
+`Shortcut`'s derived `PartialEq` compared character keys byte for byte while
+`matches()` compared them case-insensitively, so `Ctrl+S` and `Ctrl+s` were one
+keypress at the keyboard and two distinct values in memory — and the collision
+detector quietly failed to report exactly the collision it exists to catch. Both
+now go through one `same_key`.
+
+**The palette** is the node palette's mechanism, not a new widget: the same
+`cce-cloud --dmenu` popup at the cursor, with the same keys. Two pickers in one
+app that look and behave differently is worse than either. Ranking is
+`fuzzy_rank`, which reproduces the plugin's fuzzyfinder exactly — shortest
+contiguous span, then earliest start, then alphabetical — so muscle memory
+survives the move; the focused pane's commands are then partitioned to the
+front, stably, without dropping anything (a palette that hides what you are
+looking for is worse than one that lists it second). Rows are the label padded
+to a column and then its chord, so the palette teaches the keyboard rather than
+replacing it; padded rather than tab-separated because the popup renders a tab
+as one literal stop and the chords came out ragged. A row is matched back to its
+command by the LONGEST label it starts with, since "Save" starts "Save As"'s
+row.
+
+`test_every_menu_command_names_a_label_that_is_dispatched` scans `app.rs` for
+`execute_menu_action`'s arms. Scanning source is an odd way to assert it, but
+the alternative is calling every command to see whether it is handled, and
+"Exit" would end the test run. It is the check the plugin's `hccommands.py` doc
+argues for: a label kept in two places drifts, and a renamed one fails silently
+— the dispatch falls through its match and the command does nothing.
+
### Runtime paths point into the source tree
Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/shapeshifter.md b/shapeshifter.md
index 0d8d95c..f6a5f6e 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -329,6 +329,39 @@ Touches: `geometry.rs`, `nodes/*.json`.
> collapse coming due — a pane of twelve irrelevant rows is worse than the
> twelve nodes it replaced — and it is the first Phase 5 item because it was
> the binding constraint on using what Phases 1 to 4 built.
+>
+> **The command registry and the palette landed.** `src/command.rs` is one list
+> of everything the app can do — id, label, context, how to run it, default
+> chord — and `ShortcutManager` now binds command IDS rather than `Action`s,
+> which is what lets a chord reach a menu-dispatched command like Open at all.
+> `State::run_command(id)` is the single entry point and is exposed over MCP,
+> so every command is scriptable.
+>
+> The palette is the node palette's `cce-cloud --dmenu` popup rather than a new
+> widget: two pickers in one app that behave differently is worse than either.
+> Ranking reproduces the plugin's fuzzyfinder exactly — shortest span, earliest
+> start, alphabetical — so muscle memory survives; the focused pane's commands
+> partition to the front without anything being hidden. Rows carry their chord
+> in a column, so the palette teaches the keyboard instead of replacing it.
+>
+> Two findings the registry paid for immediately. The toolkit's runner already
+> claims ctrl+z, ctrl+shift+z, ctrl+tab and ctrl+shift+tab before the app sees
+> them, so undo and redo ship with no chord HERE on purpose — binding ctrl+z
+> would have taken undo away from focused text boxes while looking like a fix.
+> And `Shortcut`'s derived `PartialEq` compared character keys case-sensitively
+> while `matches()` compared them case-insensitively: `Ctrl+S` and `Ctrl+s` were
+> one keypress at the keyboard and two values in memory, so the new conflict
+> detector silently failed to report the very collision it exists to catch.
+>
+> The hotkey file this phase asked for turns out to be already built and better
+> than proposed: `input.kdl` is workspace-wide with per-app domains, so a chord
+> is `cce-designer.<id>` there and the registry supplies the default. What was
+> missing was not a file but a set of NAMES to put in it, and conflict
+> reporting; both are in.
+>
+> Still outstanding for this phase: keyboard graph navigation and auto-layout
+> in the network pane, and the viewer-state framework generalized out of
+> `curve_tool.rs`.
Independent of all the geometry work, and the place where the app gets to be
better rather than equal. A **command palette** on the HC Panel's model — fuzzy
diff --git a/src/api.rs b/src/api.rs
index 91b2091..10fe5ed 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -250,6 +250,15 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
"required": ["widget_idx", "menu_idx"],
}),
),
+ tool(
+ "run_command",
+ "Run a command by its registry id (e.g. \"command_palette\", \"toggle_grid\", \"save_document\") — every command the palette lists, including the ones no menu label reaches.",
+ json!({
+ "type": "object",
+ "properties": { "id": { "type": "string", "description": "Command id, snake_case, as input.kdl binds it" } },
+ "required": ["id"],
+ }),
+ ),
tool(
"menu_action",
"Execute a menu action by its label (e.g. \"Show Spreadsheet Pane\", \"Save\") — reaches label-matched menu-pane items that menu_click's index dispatch cannot.",
diff --git a/src/app.rs b/src/app.rs
index b6624fd..b0c038b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -368,6 +368,10 @@ pub enum McpAction {
/// Execute a label-matched menu-pane action ("Show Spreadsheet Pane", "Save", ...)
/// — the items `menu_click`'s index-matched menubar dispatch cannot reach.
MenuAction { label: String },
+ /// Run a registry command by id — every command the palette lists and
+ /// every chord the keyboard can send, under one name. `menu_action`
+ /// reaches only the label-dispatched half.
+ RunCommand { id: String },
/// Collapse a pane to its title stub, or restore it — the plate corner
/// menu's Collapse/Expand, reachable without driving the pointer.
SetPaneCollapsed { pane: String, collapsed: bool },
@@ -389,6 +393,9 @@ pub enum CustomEvent {
/// An MCP `tools/call` from the embedded MCP server (carries its own
/// reply channel) — the tool name is an `McpAction` tag, or `get_state`.
McpCall(cce_ui::mcp::McpToolCall),
+ /// A command chosen in the palette, which runs on its own thread and so
+ /// cannot touch `State` — it sends the id back to the event loop instead.
+ RunCommand(&'static str),
/// A fire-and-forget action from an app-internal thread (the cce-files
/// choosers deliver their picked path this way).
RunAction(McpAction),
@@ -1066,7 +1073,9 @@ pub struct State {
pub last_click: Option<(Instant, usize)>,
pub shortcut_manager: ShortcutManager,
- pub pending_action: Option<Action>,
+ /// A chord matched this frame, run on the next tick — the command's
+ /// ID, since a chord binds a registry row rather than an `Action`.
+ pub pending_command: Option<&'static str>,
pub exit_requested: bool,
/// Engine event-loop sender so app-spawned threads (the cce-files
/// choosers) can deliver results back as `CustomEvent`s; set once by
@@ -3943,31 +3952,42 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// Chords from input.kdl (`cce-designer` domain → `cce-ui` domain),
- // defaulting to the historical bindings; an invalid user chord logs
- // and falls back to the default instead of panicking.
+ // defaulting to what the command registry declares; an invalid user
+ // chord logs and falls back to the default instead of panicking.
+ //
+ // One loop over the registry, not a list kept beside it. The old hand-
+ // written block was where a command went to be forgotten: undo was an
+ // Action and an Edit-menu item and appeared here in neither, so the app
+ // shipped with no Ctrl+Z and nothing that could have told you.
let mut shortcut_manager = ShortcutManager::new();
{
- let mut register = |name: &str, default: &str, action: Action| {
- let chord = cce_ui::input::app_chord(name, default);
- if shortcut_manager.register(&chord, action).is_err() {
- eprintln!("[cce-designer] invalid chord {:?} for {}; using {:?}", chord, name, default);
- let _ = shortcut_manager.register(default, action);
+ let mut resolved: Vec<(&'static str, String)> = Vec::new();
+ for cmd in crate::command::COMMANDS {
+ let Some(default) = cmd.default_chord else { continue };
+ let chord = cce_ui::input::app_chord(cmd.id, default);
+ if shortcut_manager.register(&chord, cmd.id).is_err() {
+ eprintln!(
+ "[cce-designer] invalid chord {:?} for {}; using {:?}",
+ chord, cmd.id, default
+ );
+ let _ = shortcut_manager.register(default, cmd.id);
+ resolved.push((cmd.id, default.to_string()));
+ } else {
+ resolved.push((cmd.id, chord));
}
- };
- register("toggle_grid", "Ctrl+g", Action::ToggleGrid);
- register("toggle_cube", "Ctrl+e", Action::ToggleCube);
- register("toggle_square_viewport", "Ctrl+a", Action::ToggleSquareViewport);
- register("toggle_configure", "Ctrl+,", Action::ToggleConfigure);
- register("toggle_spreadsheet", "`", Action::ToggleSpreadsheet);
- register("toggle_circular_pane", "Ctrl+d", Action::ToggleCircularPane);
- register("save_document", "Ctrl+s", Action::Save);
- register("save_document_as", "Ctrl+Shift+s", Action::SaveAs);
- register("next_context", "Ctrl+Tab", Action::NextContext);
- register("previous_context", "Ctrl+Shift+Tab", Action::PrevContext);
- register("play_pause", "Up", Action::PlayPause);
- register("play_pause_reverse", "Down", Action::PlayPauseReverse);
- register("frame_next", "Right", Action::FrameNext);
- register("frame_prev", "Left", Action::FramePrev);
+ }
+ // Two commands on one chord make the second unreachable in silence
+ // — it looks like a broken command rather than a broken binding —
+ // so say which lost and to whom.
+ for c in crate::command::conflicts(&resolved) {
+ eprintln!(
+ "[cce-designer] chord {:?} is bound to {} and to {}; {} wins",
+ c.chord,
+ c.winner,
+ c.shadowed.join(", "),
+ c.winner
+ );
+ }
}
let mut state = Self {
@@ -3998,7 +4018,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
node_clipboard: None,
last_click: None,
shortcut_manager,
- pending_action: None,
+ pending_command: None,
exit_requested: false,
event_sender: None,
slots,
@@ -5016,9 +5036,91 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.sync_pane_focus();
}
+ /// Run a command by its registry id — the one entry point the chord, the
+ /// palette and (soon) the menus all go through.
+ ///
+ /// Returns false for an id that names no command, which is what a stale
+ /// `input.kdl` binding looks like.
+ pub fn run_command(&mut self, id: &str) -> bool {
+ let Some(cmd) = crate::command::by_id(id) else {
+ self.update_status_text(&format!("No command named '{id}'"));
+ return false;
+ };
+ match cmd.run {
+ crate::command::Run::Key(action) => {
+ self.execute_action(action);
+ true
+ }
+ crate::command::Run::Menu(label) => self.execute_menu_action(label),
+ }
+ }
+
+ /// The command palette: every command, fuzzy-searched, focused pane first.
+ ///
+ /// It is the node palette's mechanism rather than a new widget — the same
+ /// `cce-cloud --dmenu` popup, at the cursor, with the same keys — because
+ /// two pickers in one app that look and behave differently is worse than
+ /// either. The proposal's point about Houdini was that a palette should not
+ /// exist to work around a missing API; it does not, here.
+ pub fn open_command_palette(&mut self) {
+ const SOURCE: &str = "command-palette";
+ if self.cloud_popups.click(SOURCE) == cce_ui::process::CloudPopupClick::ToggledOff {
+ return;
+ }
+ let Some(sender) = self.event_sender.clone() else { return };
+ // Ranked here, not in the popup: the popup filters as you type, but the
+ // ORDER it starts from is ours, and that is where the focused pane's
+ // commands come first.
+ let entries = crate::command::palette_entries("", self.focused_context());
+ // The chord goes on the row so the palette teaches the keyboard rather
+ // than replacing it. Padded to a column rather than separated by a tab:
+ // the popup renders a tab as one literal tab stop, so the chords came
+ // out ragged and stopped reading as a column at all.
+ let width = entries.iter().map(|c| c.label.len()).max().unwrap_or(0) + 2;
+ let items: String = entries
+ .iter()
+ .map(|c| {
+ let chord = self.shortcut_manager.chord_for(c.id).map(|s| s.describe());
+ crate::command::palette_row(c.label, chord, width)
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+ let (px, py) = (self.cursor_x as i32, self.cursor_y as i32);
+ std::thread::spawn(move || {
+ let popup = cce_ui::process::CloudPopup::at(px, py).parent_app_id("cce-designer");
+ let mut spawned_pid = 0;
+ let result = popup.run_dmenu("Command:", &items, |pid| {
+ spawned_pid = pid;
+ let _ = sender.send(CustomEvent::CloudSpawned { pid, source: SOURCE.to_string() });
+ });
+ if let Ok(Some(selected)) = &result {
+ // The row is the label padded out, then its chord. Match the
+ // LONGEST label the row starts with, so "Save" cannot claim a
+ // row that belongs to "Save As".
+ if let Some(cmd) = crate::command::from_palette_row(selected) {
+ let _ = sender.send(CustomEvent::RunCommand(cmd.id));
+ }
+ }
+ let _ = sender.send(CustomEvent::CloudClosed { pid: spawned_pid, source: SOURCE.to_string() });
+ });
+ }
+
+ /// The command context of the focused pane, for palette ranking.
+ pub fn focused_context(&self) -> crate::command::Context {
+ use crate::command::Context;
+ match self.focused_pane {
+ LEFT_MENUBAR_IDX => Context::Network,
+ RIGHT_MENUBAR_IDX => Context::Viewport,
+ PARAM_MENUBAR_IDX => Context::Parameters,
+ SPREADSHEET_MENUBAR_IDX => Context::Spreadsheet,
+ _ => Context::Always,
+ }
+ }
+
pub fn execute_action(&mut self, action: Action) {
let mut settings_changed = false;
match action {
+ Action::CommandPalette => self.open_command_palette(),
// Undo/Redo reach whichever editing state owns a history. The
// chords arrive through `Application::undo` / `redo` (the
// toolkit routes them, after the focused text box's turn); the
@@ -6435,10 +6537,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// Context switching dispatches ahead of the widget key paths
// (param pane, node palette) so the chord works from any pane.
if event.state == ElementState::Pressed {
- if let Some(action @ (Action::NextContext | Action::PrevContext)) =
- self.shortcut_manager.match_action(&self.modifiers, &event.logical_key)
+ if let Some(id @ ("next_context" | "previous_context")) =
+ self.shortcut_manager.match_command(&self.modifiers, &event.logical_key)
{
- self.execute_action(action);
+ self.run_command(id);
return true;
}
}
@@ -6456,12 +6558,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// fire once per physical press, or a held key would flicker
// play/pause at the repeat rate.
if event.state == ElementState::Pressed {
- if let Some(action @ (Action::PlayPause | Action::PlayPauseReverse | Action::FrameNext | Action::FramePrev)) =
- self.shortcut_manager.match_action(&self.modifiers, &event.logical_key)
+ if let Some(id @ ("play_pause" | "play_pause_reverse" | "frame_next" | "frame_prev")) =
+ self.shortcut_manager.match_command(&self.modifiers, &event.logical_key)
{
- let is_toggle = matches!(action, Action::PlayPause | Action::PlayPauseReverse);
+ let is_toggle = matches!(id, "play_pause" | "play_pause_reverse");
if !(is_toggle && event.repeat) {
- self.execute_action(action);
+ self.run_command(id);
}
return true;
}
@@ -6773,8 +6875,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.open_node_palette();
return true;
}
- if let Some(action) = self.shortcut_manager.match_action(&self.modifiers, &event.logical_key) {
- self.pending_action = Some(action);
+ if let Some(id) = self.shortcut_manager.match_command(&self.modifiers, &event.logical_key) {
+ self.pending_command = Some(id);
return true;
}
}
diff --git a/src/command.rs b/src/command.rs
new file mode 100644
index 0000000..e1546c2
--- /dev/null
+++ b/src/command.rs
@@ -0,0 +1,268 @@
+//! The command registry — one list of everything the app can be asked to do.
+//!
+//! Before this there were three vocabularies with nothing holding them
+//! together: the [`Action`] enum matched against chords, the menu-item LABELS
+//! that `execute_menu_action` dispatches on, and the menubar declarations that
+//! put those labels on screen. A command existed in whichever of them someone
+//! had needed at the time, so "Show Origin" was an `Action` with no chord, undo
+//! was a menu item with no chord, and nothing could tell you either fact.
+//!
+//! The plugin this app is replacing learned the same lesson and wrote it down:
+//! its `hccommands.py` moved the label onto the method as a decorator because
+//! a `label -> method` map maintained beside the methods drifted from them, and
+//! a renamed method silently emptied the panel. Rust has no decorators, so the
+//! equivalent is one static table where each command carries everything about
+//! itself, plus a test that every chord and every dispatched label names a row
+//! in it. There is still only one place to forget.
+//!
+//! What a registry entry is NOT is a second implementation. [`Run`] names the
+//! path a command already takes — an `Action` or a menu label — so the palette,
+//! the chord and the menu all end up in the same code. A command with two ways
+//! to run it would be two commands that drift.
+
+use crate::shortcut::Action;
+
+/// Where a command applies.
+///
+/// The palette ranks by this: commands for the focused pane come first, then
+/// everything global. It is not a filter — a pane-specific command is still
+/// reachable from anywhere, because a palette that hides what you are looking
+/// for is worse than one that lists it second. That is the opposite of the
+/// plugin's rule, which drops commands a tab cannot run; the difference is that
+/// there every tab genuinely could not run them, while here every pane exists
+/// at once and "the viewport's grid" is a thing you may well want from the
+/// network editor.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Context {
+ Always,
+ Network,
+ Viewport,
+ Parameters,
+ Spreadsheet,
+ Playbar,
+}
+
+/// How a command reaches the code that does the work.
+///
+/// Two variants because the app genuinely has two dispatch paths, and
+/// pretending otherwise would mean rewriting one of them to look like the
+/// other for no gain. What matters is that a command names exactly one.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Run {
+ /// Through `State::execute_action` — the typed path, and the only one a
+ /// chord could reach before this.
+ Key(Action),
+ /// Through `State::execute_menu_action` by label — the menubar's path.
+ Menu(&'static str),
+}
+
+pub struct Command {
+ /// Stable and snake_case: this is what `input.kdl` binds and what a
+ /// conflict report names. It must not change when the label does — the
+ /// case follows that file's existing convention (`zoom_in`,
+ /// `close_window`), since being what the user types there is the id's
+ /// whole job.
+ pub id: &'static str,
+ /// What a human reads, in the palette and in the menus.
+ pub label: &'static str,
+ pub context: Context,
+ pub run: Run,
+ /// The chord this command has when the user has not said otherwise.
+ /// `None` means the command ships unbound and is reachable only through a
+ /// menu or the palette — which is a fine thing to be, but now a visible
+ /// one.
+ pub default_chord: Option<&'static str>,
+}
+
+/// Every command, in the order the palette lists ties.
+///
+/// **A `default_chord` of `None` does not always mean unbound.** The toolkit's
+/// runner claims four chords of its own from `input.kdl`'s `cce-ui` domain
+/// before an app sees them: `undo` (ctrl+z), `redo` (ctrl+shift+z),
+/// `focus_next_group` (ctrl+tab) and `focus_prev_group` (ctrl+shift+tab). Undo
+/// and Redo are listed here with no chord for exactly that reason — they are
+/// routed to the focused widget FIRST, so a text box undoes its own typing
+/// before the app is asked, and registering ctrl+z here would take that away
+/// while looking like a fix. The palette still runs them, which is the gain.
+///
+/// Focus Next/Previous Pane do claim ctrl+tab and ctrl+shift+tab, overriding
+/// the runner's group-focus chords. That is deliberate and predates this
+/// registry; it is recorded here because `conflicts()` cannot see it — that
+/// check compares this app's bindings with each other, not with the toolkit's.
+pub const COMMANDS: &[Command] = &[
+ // --- File ---
+ Command { id: "new_project", label: "New Project", context: Context::Always, run: Run::Menu("New Project"), default_chord: Some("Ctrl+n") },
+ Command { id: "open_project", label: "Open", context: Context::Always, run: Run::Menu("Open"), default_chord: Some("Ctrl+o") },
+ Command { id: "save_document", label: "Save", context: Context::Always, run: Run::Key(Action::Save), default_chord: Some("Ctrl+s") },
+ Command { id: "save_document_as", label: "Save As", context: Context::Always, run: Run::Key(Action::SaveAs), default_chord: Some("Ctrl+Shift+s") },
+ Command { id: "set_as_default", label: "Set As Default", context: Context::Always, run: Run::Menu("Set As Default"), default_chord: None },
+ Command { id: "exit", label: "Exit", context: Context::Always, run: Run::Menu("Exit"), default_chord: None },
+
+ // --- Edit ---
+ // Chordless on purpose: the runner owns ctrl+z / ctrl+shift+z. See above.
+ Command { id: "undo", label: "Undo", context: Context::Always, run: Run::Key(Action::Undo), default_chord: None },
+ Command { id: "redo", label: "Redo", context: Context::Always, run: Run::Key(Action::Redo), default_chord: None },
+
+ // --- Panes ---
+ Command { id: "show_network_pane", label: "Show Network Pane", context: Context::Always, run: Run::Menu("Show Network Pane"), default_chord: None },
+ Command { id: "show_viewport_pane", label: "Show Viewport Pane", context: Context::Always, run: Run::Menu("Show Viewport Pane"), default_chord: None },
+ Command { id: "show_parameters_pane", label: "Show Parameters Pane", context: Context::Always, run: Run::Menu("Show Parameters Pane"), default_chord: None },
+ Command { id: "toggle_spreadsheet", label: "Show Spreadsheet Pane", context: Context::Always, run: Run::Key(Action::ToggleSpreadsheet), default_chord: Some("`") },
+ Command { id: "show_playbar_pane", label: "Show Playbar Pane", context: Context::Always, run: Run::Menu("Show Playbar Pane"), default_chord: None },
+ Command { id: "close_pane", label: "Close Pane", context: Context::Always, run: Run::Menu("Close Pane"), default_chord: None },
+ Command { id: "next_context", label: "Focus Next Pane", context: Context::Always, run: Run::Key(Action::NextContext), default_chord: Some("Ctrl+Tab") },
+ Command { id: "previous_context", label: "Focus Previous Pane", context: Context::Always, run: Run::Key(Action::PrevContext), default_chord: Some("Ctrl+Shift+Tab") },
+ Command { id: "command_palette", label: "Command Palette", context: Context::Always, run: Run::Key(Action::CommandPalette), default_chord: Some("Ctrl+p") },
+ Command { id: "toggle_configure", label: "Configure", context: Context::Always, run: Run::Key(Action::ToggleConfigure), default_chord: Some("Ctrl+,") },
+
+ // --- Network ---
+ Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
+ Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
+ Command { id: "reset_zoom", label: "Reset Zoom", context: Context::Network, run: Run::Menu("Reset Zoom"), default_chord: None },
+ Command { id: "toggle_circular_pane", label: "Circular Pane", context: Context::Network, run: Run::Key(Action::ToggleCircularPane), default_chord: Some("Ctrl+d") },
+ Command { id: "detach_circular_window", label: "Detach Circular Window", context: Context::Network, run: Run::Key(Action::DetachCircularWindow), default_chord: None },
+
+ // --- Viewport ---
+ Command { id: "toggle_grid", label: "Show Grid", context: Context::Viewport, run: Run::Key(Action::ToggleGrid), default_chord: Some("Ctrl+g") },
+ Command { id: "toggle_cube", label: "Show Cube", context: Context::Viewport, run: Run::Key(Action::ToggleCube), default_chord: Some("Ctrl+e") },
+ Command { id: "toggle_origin", label: "Show Origin", context: Context::Viewport, run: Run::Key(Action::ToggleOrigin), default_chord: None },
+ Command { id: "toggle_camera_pivot", label: "Show Camera Pivot", context: Context::Viewport, run: Run::Key(Action::ToggleCameraPivot), default_chord: None },
+ Command { id: "toggle_square_viewport", label: "Square Aspect", context: Context::Viewport, run: Run::Key(Action::ToggleSquareViewport), default_chord: Some("Ctrl+a") },
+
+ // --- Parameters ---
+ Command { id: "export", label: "Export", context: Context::Parameters, run: Run::Menu("Export"), default_chord: None },
+ Command { id: "update_parameters", label: "Update Parameters", context: Context::Parameters, run: Run::Menu("Update Parameters"), default_chord: None },
+
+ // --- Playbar ---
+ Command { id: "play_pause", label: "Play / Pause", context: Context::Playbar, run: Run::Key(Action::PlayPause), default_chord: Some("Up") },
+ Command { id: "play_pause_reverse", label: "Play / Pause Reverse", context: Context::Playbar, run: Run::Key(Action::PlayPauseReverse), default_chord: Some("Down") },
+ Command { id: "frame_next", label: "Next Frame", context: Context::Playbar, run: Run::Key(Action::FrameNext), default_chord: Some("Right") },
+ Command { id: "frame_prev", label: "Previous Frame", context: Context::Playbar, run: Run::Key(Action::FramePrev), default_chord: Some("Left") },
+];
+
+pub fn by_id(id: &str) -> Option<&'static Command> {
+ COMMANDS.iter().find(|c| c.id == id)
+}
+
+/// A chord claimed by more than one command.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Conflict {
+ pub chord: String,
+ /// The command that wins — the earlier one in [`COMMANDS`], because
+ /// matching is first-wins over registration order.
+ pub winner: &'static str,
+ /// The commands that are consequently unreachable by this chord.
+ pub shadowed: Vec<&'static str>,
+}
+
+/// Which chords two or more commands claim, given each command's RESOLVED
+/// chord (the default, or the user's override from `input.kdl`).
+///
+/// Worth detecting because the failure is silent and looks like a broken
+/// command rather than a broken binding: `match_action` returns the first
+/// match, so the second command simply never runs and says nothing about why.
+/// Chords are compared as parsed, not as text, so "Ctrl+S" and "ctrl+s"
+/// collide the way they actually do at the keyboard.
+pub fn conflicts(resolved: &[(&'static str, String)]) -> Vec<Conflict> {
+ let mut seen: Vec<(crate::shortcut::Shortcut, String, &'static str, Vec<&'static str>)> =
+ Vec::new();
+ for (id, chord) in resolved {
+ let Ok(parsed) = crate::shortcut::Shortcut::parse(chord) else { continue };
+ match seen.iter_mut().find(|(s, _, _, _)| *s == parsed) {
+ Some((_, _, _, shadowed)) => shadowed.push(id),
+ None => seen.push((parsed, chord.clone(), id, Vec::new())),
+ }
+ }
+ seen.into_iter()
+ .filter(|(_, _, _, shadowed)| !shadowed.is_empty())
+ .map(|(_, chord, winner, shadowed)| Conflict { chord, winner, shadowed })
+ .collect()
+}
+
+/// Rank `haystack` against a fuzzy `query`, returning the matching indices
+/// best first.
+///
+/// The ranking is the plugin's fuzzyfinder, deliberately: shortest contiguous
+/// span containing the query's characters in order, then earliest start, then
+/// alphabetical. Keeping the ranking means muscle memory survives the move —
+/// typing "sg" has to keep landing on Show Grid.
+///
+/// An empty query matches everything, in registry order, which is what makes
+/// the palette usable as a plain list of what exists.
+pub fn fuzzy_rank(query: &str, haystack: &[&str]) -> Vec<usize> {
+ let needle: Vec<char> = query.to_lowercase().chars().filter(|c| !c.is_whitespace()).collect();
+ if needle.is_empty() {
+ return (0..haystack.len()).collect();
+ }
+ let mut scored: Vec<(usize, usize, &str, usize)> = Vec::new();
+ for (i, item) in haystack.iter().enumerate() {
+ let chars: Vec<char> = item.to_lowercase().chars().collect();
+ // The shortest window containing the needle as a subsequence: try each
+ // start, take the first that matches, keep the tightest. The plugin
+ // gets this from an overlapping-match lookahead regex; the loop is the
+ // same answer without the regex engine.
+ let mut best: Option<(usize, usize)> = None;
+ for start in 0..chars.len() {
+ if chars[start] != needle[0] {
+ continue;
+ }
+ let mut n = 1;
+ let mut end = start + 1;
+ while end < chars.len() && n < needle.len() {
+ if chars[end] == needle[n] {
+ n += 1;
+ }
+ end += 1;
+ }
+ if n == needle.len() {
+ let span = end - start;
+ if best.is_none_or(|(b, _)| span < b) {
+ best = Some((span, start));
+ }
+ }
+ }
+ if let Some((span, start)) = best {
+ scored.push((span, start, item, i));
+ }
+ }
+ scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
+ scored.into_iter().map(|(_, _, _, i)| i).collect()
+}
+
+/// One palette row: the label padded to `width`, then its chord.
+///
+/// Padded rather than tab-separated because the popup renders a tab as a
+/// single literal tab stop, so chords after labels of different lengths do not
+/// line up into a column.
+pub fn palette_row(label: &str, chord: Option<String>, width: usize) -> String {
+ match chord {
+ Some(chord) => format!("{label:<width$}{chord}"),
+ None => label.to_string(),
+ }
+}
+
+/// The command a palette row names.
+///
+/// The LONGEST label the row starts with, because labels prefix each other:
+/// "Save" starts the row that belongs to "Save As", and picking the first
+/// match would run the wrong command from a padded row.
+pub fn from_palette_row(row: &str) -> Option<&'static Command> {
+ let row = row.trim_end();
+ COMMANDS.iter().filter(|c| row.starts_with(c.label)).max_by_key(|c| c.label.len())
+}
+
+/// The commands to offer, ranked: `query` decides which, `focused` decides the
+/// order among equals.
+pub fn palette_entries(query: &str, focused: Context) -> Vec<&'static Command> {
+ let labels: Vec<&str> = COMMANDS.iter().map(|c| c.label).collect();
+ let mut ranked: Vec<&'static Command> =
+ fuzzy_rank(query, &labels).into_iter().map(|i| &COMMANDS[i]).collect();
+ // A stable partition, so the fuzzy ranking survives inside each half.
+ if focused != Context::Always {
+ let (mine, rest): (Vec<_>, Vec<_>) =
+ ranked.into_iter().partition(|c| c.context == focused);
+ ranked = mine;
+ ranked.extend(rest);
+ }
+ ranked
+}
diff --git a/src/main.rs b/src/main.rs
index cc27277..bd081c3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,6 +23,7 @@ pub mod project;
pub mod render;
pub mod shortcut;
pub mod slots;
+pub mod command;
pub mod page;
pub mod thumbnail;
@@ -801,8 +802,8 @@ mod tests {
#[test]
fn test_save_as_chord() {
let mut m = ShortcutManager::new();
- m.register("Ctrl+s", Action::Save).unwrap();
- m.register("Ctrl+Shift+s", Action::SaveAs).unwrap();
+ m.register("Ctrl+s", "save_document").unwrap();
+ m.register("Ctrl+Shift+s", "save_document_as").unwrap();
let ctrl = crate::app::ModifiersState { ctrl: true, ..Default::default() };
let ctrl_shift = crate::app::ModifiersState { ctrl: true, shift: true, ..Default::default() };
// The REAL event shapes: xkb delivers the shifted character when
@@ -811,9 +812,9 @@ mod tests {
// fire in practice.
let lower = cce_ui::widget::Key::Character("s".into());
let upper = cce_ui::widget::Key::Character("S".into());
- assert_eq!(m.match_action(&ctrl, &lower), Some(Action::Save));
- assert_eq!(m.match_action(&ctrl_shift, &upper), Some(Action::SaveAs));
- assert_eq!(m.match_action(&ctrl_shift, &lower), Some(Action::SaveAs));
+ assert_eq!(m.match_command(&ctrl, &lower), Some("save_document"));
+ assert_eq!(m.match_command(&ctrl_shift, &upper), Some("save_document_as"));
+ assert_eq!(m.match_command(&ctrl_shift, &lower), Some("save_document_as"));
}
/// Plates support tabs: a pane pulled into another dock rides it as a
@@ -1109,17 +1110,17 @@ mod tests {
fn test_playbar_transport_keys() {
use cce_ui::widget::{Key, NamedKey};
let mut m = ShortcutManager::new();
- m.register("Up", Action::PlayPause).unwrap();
- m.register("Right", Action::FrameNext).unwrap();
- m.register("Left", Action::FramePrev).unwrap();
+ m.register("Up", "play_pause").unwrap();
+ m.register("Right", "frame_next").unwrap();
+ m.register("Left", "frame_prev").unwrap();
let plain = crate::app::ModifiersState::default();
let ctrl = crate::app::ModifiersState { ctrl: true, ..Default::default() };
- assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowUp)), Some(Action::PlayPause));
- assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowRight)), Some(Action::FrameNext));
- assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowLeft)), Some(Action::FramePrev));
- assert_eq!(m.match_action(&ctrl, &Key::Named(NamedKey::ArrowUp)), None);
- m.register("Down", Action::PlayPauseReverse).unwrap();
- assert_eq!(m.match_action(&plain, &Key::Named(NamedKey::ArrowDown)), Some(Action::PlayPauseReverse));
+ assert_eq!(m.match_command(&plain, &Key::Named(NamedKey::ArrowUp)), Some("play_pause"));
+ assert_eq!(m.match_command(&plain, &Key::Named(NamedKey::ArrowRight)), Some("frame_next"));
+ assert_eq!(m.match_command(&plain, &Key::Named(NamedKey::ArrowLeft)), Some("frame_prev"));
+ assert_eq!(m.match_command(&ctrl, &Key::Named(NamedKey::ArrowUp)), None);
+ m.register("Down", "play_pause_reverse").unwrap();
+ assert_eq!(m.match_command(&plain, &Key::Named(NamedKey::ArrowDown)), Some("play_pause_reverse"));
}
/// Either play toggle pauses a moving timeline; direction only chooses
@@ -3288,31 +3289,31 @@ mod tests {
// Test register and match
let mut mgr = ShortcutManager::new();
- mgr.register("Ctrl+g", Action::ToggleGrid).unwrap();
- mgr.register("`", Action::ToggleSpreadsheet).unwrap();
+ mgr.register("Ctrl+g", "toggle_grid").unwrap();
+ mgr.register("`", "toggle_spreadsheet").unwrap();
// Matches with ctrl and g
let mods_ctrl = ModifiersState { ctrl: true, alt: false, shift: false, logo: false };
let key_g = Key::Character("g".to_string());
- assert_eq!(mgr.match_action(&mods_ctrl, &key_g), Some(Action::ToggleGrid));
+ assert_eq!(mgr.match_command(&mods_ctrl, &key_g), Some("toggle_grid"));
// No match with ctrl and a
let key_a = Key::Character("a".to_string());
- assert_eq!(mgr.match_action(&mods_ctrl, &key_a), None);
+ assert_eq!(mgr.match_command(&mods_ctrl, &key_a), None);
// Matches backtick with no modifiers
let mods_none = ModifiersState::default();
let key_tick = Key::Character("`".to_string());
- assert_eq!(mgr.match_action(&mods_none, &key_tick), Some(Action::ToggleSpreadsheet));
+ assert_eq!(mgr.match_command(&mods_none, &key_tick), Some("toggle_spreadsheet"));
// Context cycling chords: exact modifier match separates next from previous
- mgr.register("Ctrl+Tab", Action::NextContext).unwrap();
- mgr.register("Ctrl+Shift+Tab", Action::PrevContext).unwrap();
+ mgr.register("Ctrl+Tab", "next_context").unwrap();
+ mgr.register("Ctrl+Shift+Tab", "previous_context").unwrap();
let key_tab = Key::Named(NamedKey::Tab);
let mods_ctrl_shift = ModifiersState { ctrl: true, alt: false, shift: true, logo: false };
- assert_eq!(mgr.match_action(&mods_ctrl, &key_tab), Some(Action::NextContext));
- assert_eq!(mgr.match_action(&mods_ctrl_shift, &key_tab), Some(Action::PrevContext));
- assert_eq!(mgr.match_action(&mods_none, &key_tab), None);
+ assert_eq!(mgr.match_command(&mods_ctrl, &key_tab), Some("next_context"));
+ assert_eq!(mgr.match_command(&mods_ctrl_shift, &key_tab), Some("previous_context"));
+ assert_eq!(mgr.match_command(&mods_none, &key_tab), None);
}
#[test]
@@ -3913,6 +3914,192 @@ mod tests {
assert!(lx > cx && cx > rx, "alignment did not move the ink: {lx} {cx} {rx}");
}
+ /// Fuzzy ranking is the plugin's fuzzyfinder, deliberately: shortest
+ /// contiguous span, then earliest start, then alphabetical. Muscle memory
+ /// is the whole point of keeping it — "sg" has to keep landing on Show
+ /// Grid.
+ #[test]
+ fn test_fuzzy_ranking_matches_the_plugins_order() {
+ use crate::command::fuzzy_rank;
+ let items = ["Show Grid", "Show Spreadsheet Pane", "Save As", "Set As Default"];
+
+ // Subsequence, not substring.
+ let r = fuzzy_rank("sg", &items);
+ assert_eq!(items[r[0]], "Show Grid", "sg did not rank Show Grid first: {r:?}");
+
+ // The tightest span wins over the earliest start: "sa" spans 2 in
+ // "Save As" (Sa) and more in the others.
+ let r = fuzzy_rank("sa", &items);
+ assert_eq!(items[r[0]], "Save As");
+
+ // No match at all drops out rather than ranking last.
+ assert!(fuzzy_rank("zzz", &items).is_empty());
+
+ // An empty query is every item in registry order, which is what makes
+ // the palette usable as a plain list.
+ assert_eq!(fuzzy_rank("", &items), vec![0, 1, 2, 3]);
+
+ // Case and spaces in the query are ignored.
+ assert_eq!(fuzzy_rank("S G", &items), fuzzy_rank("sg", &items));
+ }
+
+ /// The registry's own invariants. Ids are what `input.kdl` binds and
+ /// labels are what the palette maps a chosen row back to, so a duplicate
+ /// of either silently runs the wrong command.
+ #[test]
+ fn test_the_command_registry_is_consistent() {
+ use crate::command::COMMANDS;
+ let mut ids: Vec<&str> = COMMANDS.iter().map(|c| c.id).collect();
+ let n = ids.len();
+ ids.sort_unstable();
+ ids.dedup();
+ assert_eq!(ids.len(), n, "duplicate command id");
+
+ let mut labels: Vec<&str> = COMMANDS.iter().map(|c| c.label).collect();
+ labels.sort_unstable();
+ labels.dedup();
+ assert_eq!(labels.len(), n, "duplicate command label: the palette picks by label");
+
+ for c in COMMANDS {
+ assert!(
+ c.id.chars().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'),
+ "{} is not snake_case, which is what input.kdl writes",
+ c.id
+ );
+ if let Some(chord) = c.default_chord {
+ crate::shortcut::Shortcut::parse(chord)
+ .unwrap_or_else(|e| panic!("{}: unparseable default chord {chord:?}: {e}", c.id));
+ }
+ }
+ }
+
+ /// Every menu-dispatched command names a label `execute_menu_action`
+ /// actually handles.
+ ///
+ /// This is the check the plugin's hccommands.py doc argues for: a label
+ /// kept in two places drifts, and a renamed one fails SILENTLY — the
+ /// dispatch falls through its match and the command simply does nothing.
+ /// Scanning the source is an odd way to assert it, but the alternative is
+ /// calling every command to see if it is handled, and "Exit" would end the
+ /// test run.
+ #[test]
+ fn test_every_menu_command_names_a_label_that_is_dispatched() {
+ use crate::command::{Run, COMMANDS};
+ let src = include_str!("app.rs");
+ let start = src
+ .find("pub fn execute_menu_action")
+ .expect("execute_menu_action moved; this test scans for it");
+ let body = &src[start..];
+ for c in COMMANDS {
+ let Run::Menu(label) = c.run else { continue };
+ let arm = format!("\"{label}\"");
+ assert!(
+ body.contains(&arm),
+ "command {} dispatches {label:?}, which execute_menu_action does not handle",
+ c.id
+ );
+ }
+ }
+
+ /// Two commands on one chord is silent at the keyboard — the second never
+ /// runs and nothing says why — so it is reported at startup.
+ #[test]
+ fn test_chord_conflicts_are_detected_across_spellings() {
+ use crate::command::conflicts;
+ let none = conflicts(&[("save_document", "Ctrl+s".into()), ("undo", "Ctrl+z".into())]);
+ assert!(none.is_empty(), "{none:?}");
+
+ // Compared as PARSED chords, not as text: these are the same keypress.
+ let clash = conflicts(&[
+ ("save_document", "Ctrl+s".into()),
+ ("toggle_grid", "ctrl+S".into()),
+ ("undo", "CTRL+s".into()),
+ ]);
+ assert_eq!(clash.len(), 1, "{clash:?}");
+ assert_eq!(clash[0].winner, "save_document", "the first registered must win");
+ assert_eq!(clash[0].shadowed, vec!["toggle_grid", "undo"]);
+
+ // The shipped defaults must not collide with each other.
+ let shipped: Vec<(&'static str, String)> = crate::command::COMMANDS
+ .iter()
+ .filter_map(|c| c.default_chord.map(|d| (c.id, d.to_string())))
+ .collect();
+ let shipped_conflicts = conflicts(&shipped);
+ assert!(shipped_conflicts.is_empty(), "the defaults collide: {shipped_conflicts:?}");
+ }
+
+ /// The palette ranks the focused pane's commands first without hiding the
+ /// rest — a palette that omits what you are looking for is worse than one
+ /// that lists it second.
+ #[test]
+ fn test_the_palette_puts_the_focused_panes_commands_first() {
+ use crate::command::{palette_entries, Context, COMMANDS};
+ let all = palette_entries("", Context::Viewport);
+ assert_eq!(all.len(), COMMANDS.len(), "ranking dropped commands");
+ assert_eq!(
+ all[0].context,
+ Context::Viewport,
+ "a viewport command does not lead: {}",
+ all[0].id
+ );
+ assert!(
+ all.iter().any(|c| c.id == "save_document"),
+ "a global command vanished when a pane was focused"
+ );
+
+ // With nothing pane-specific focused the order is the fuzzy one alone.
+ let plain = palette_entries("", Context::Always);
+ assert_eq!(plain[0].id, COMMANDS[0].id);
+ }
+
+ /// A chord prints back the way it parsed, in a fixed modifier order, so
+ /// two spellings of one chord read the same beside their labels.
+ #[test]
+ fn test_a_chord_describes_itself_back() {
+ use crate::shortcut::Shortcut;
+ for (written, shown) in [
+ ("Ctrl+s", "Ctrl+S"),
+ ("ctrl+shift+S", "Ctrl+Shift+S"),
+ ("shift+ctrl+s", "Ctrl+Shift+S"),
+ ("`", "`"),
+ ] {
+ assert_eq!(Shortcut::parse(written).unwrap().describe(), shown);
+ }
+ // And what it prints parses back to the same chord.
+ for c in crate::command::COMMANDS.iter().filter_map(|c| c.default_chord) {
+ let parsed = Shortcut::parse(c).unwrap();
+ assert_eq!(Shortcut::parse(&parsed.describe()).unwrap(), parsed, "{c} did not round trip");
+ }
+ }
+
+ /// A palette row round-trips back to the command it names — including
+ /// when one label is a prefix of another.
+ #[test]
+ fn test_a_palette_row_names_its_command_back() {
+ use crate::command::{from_palette_row, palette_row, COMMANDS};
+ let width = COMMANDS.iter().map(|c| c.label.len()).max().unwrap() + 2;
+
+ for c in COMMANDS {
+ let row = palette_row(c.label, Some("Ctrl+X".into()), width);
+ assert_eq!(
+ from_palette_row(&row).map(|f| f.id),
+ Some(c.id),
+ "row {row:?} did not name {} back",
+ c.id
+ );
+ // And a row with no chord at all.
+ let bare = palette_row(c.label, None, width);
+ assert_eq!(from_palette_row(&bare).map(|f| f.id), Some(c.id));
+ }
+
+ // The prefix case, spelled out: "Save" starts "Save As"'s row, and
+ // taking the first match rather than the longest runs the wrong one.
+ let row = palette_row("Save As", Some("Ctrl+Shift+S".into()), width);
+ assert_eq!(from_palette_row(&row).map(|c| c.id), Some("save_document_as"));
+
+ assert!(from_palette_row("Not A Command").is_none());
+ }
+
/// A page's raster is its physical size times its resolution — the
/// property that makes DPI a page parameter rather than an export one.
#[test]
diff --git a/src/shortcut.rs b/src/shortcut.rs
index b55e338..72c58b4 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -22,9 +22,12 @@ pub enum Action {
FramePrev,
Undo,
Redo,
+ /// Open the command palette — a command like any other, so it is
+ /// rebindable and lists itself.
+ CommandPalette,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone)]
pub struct Shortcut {
pub ctrl: bool,
pub shift: bool,
@@ -82,6 +85,37 @@ impl Shortcut {
Ok(Shortcut { ctrl, shift, alt, logo, key })
}
+ /// The chord as a human reads it — the inverse of [`parse`](Self::parse),
+ /// for showing beside a command's label.
+ ///
+ /// Modifier order is fixed (Ctrl, Shift, Alt, Super) rather than however
+ /// the user happened to write it, so two spellings of one chord print the
+ /// same and a palette column stays scannable.
+ pub fn describe(&self) -> String {
+ let mut out = String::new();
+ for (on, name) in
+ [(self.ctrl, "Ctrl"), (self.shift, "Shift"), (self.alt, "Alt"), (self.logo, "Super")]
+ {
+ if on {
+ out.push_str(name);
+ out.push('+');
+ }
+ }
+ match &self.key {
+ Key::Character(c) => {
+ // Single letters read as capitals — "Ctrl+S", not "Ctrl+s" —
+ // which is how every menu in the app already writes them.
+ if c.chars().count() == 1 {
+ out.extend(c.chars().flat_map(|ch| ch.to_uppercase()));
+ } else {
+ out.push_str(c);
+ }
+ }
+ Key::Named(n) => out.push_str(&format!("{n:?}")),
+ }
+ out
+ }
+
pub fn matches(&self, mods: &ModifiersState, key: &Key) -> bool {
if mods.control_key() != self.ctrl
|| mods.shift_key() != self.shift
@@ -90,19 +124,54 @@ impl Shortcut {
{
return false;
}
- // Character keys compare case-insensitively: with Shift held, xkb
- // delivers the SHIFTED character ("S"), so an exact match against the
- // chord's stored "s" made every Shift+letter chord unmatchable —
- // Ctrl+Shift+Tab never noticed because Named keys aren't shifted.
- match (key, &self.key) {
- (Key::Character(a), Key::Character(b)) => a.eq_ignore_ascii_case(b),
- (a, b) => a == b,
- }
+ same_key(key, &self.key)
}
}
+/// Whether two keys are the same key.
+///
+/// Character keys compare case-insensitively: with Shift held, xkb delivers
+/// the SHIFTED character ("S"), so an exact match against the chord's stored
+/// "s" made every Shift+letter chord unmatchable — Ctrl+Shift+Tab never
+/// noticed because Named keys aren't shifted.
+fn same_key(a: &Key, b: &Key) -> bool {
+ match (a, b) {
+ (Key::Character(x), Key::Character(y)) => x.eq_ignore_ascii_case(y),
+ (x, y) => x == y,
+ }
+}
+
+/// Equality is what the KEYBOARD would call the same chord, which is why it is
+/// written rather than derived.
+///
+/// The derived version compared character keys byte for byte while `matches`
+/// compared them case-insensitively, so the two disagreed: `Ctrl+S` and
+/// `Ctrl+s` are one keypress at the keyboard and were two distinct `Shortcut`s
+/// in memory. Nothing noticed until `command::conflicts` started comparing
+/// chords to each other and quietly failed to report a collision between two
+/// spellings of the same binding — the exact failure it exists to catch. Both
+/// go through `same_key` now, so they cannot drift again.
+impl PartialEq for Shortcut {
+ fn eq(&self, other: &Self) -> bool {
+ self.ctrl == other.ctrl
+ && self.shift == other.shift
+ && self.alt == other.alt
+ && self.logo == other.logo
+ && same_key(&self.key, &other.key)
+ }
+}
+
+impl Eq for Shortcut {}
+
+/// Chord -> command id.
+///
+/// Ids rather than [`Action`]s because a chord has to be able to reach a
+/// command the `Action` enum does not cover — New Project and Open are menu
+/// labels, and there was no way to bind them at all while this held `Action`.
+/// What a binding names is a row in [`crate::command::COMMANDS`], and that row
+/// says how to run it.
pub struct ShortcutManager {
- bindings: Vec<(Shortcut, Action)>,
+ bindings: Vec<(Shortcut, &'static str)>,
}
impl ShortcutManager {
@@ -110,18 +179,26 @@ impl ShortcutManager {
ShortcutManager { bindings: Vec::new() }
}
- pub fn register(&mut self, shortcut_str: &str, action: Action) -> Result<(), String> {
+ pub fn register(&mut self, shortcut_str: &str, command: &'static str) -> Result<(), String> {
let shortcut = Shortcut::parse(shortcut_str)?;
- self.bindings.push((shortcut, action));
+ self.bindings.push((shortcut, command));
Ok(())
}
- pub fn match_action(&self, mods: &ModifiersState, key: &Key) -> Option<Action> {
- for (shortcut, action) in &self.bindings {
+ /// First match wins, in registration order — which is registry order. Two
+ /// commands on one chord therefore make the second unreachable in silence,
+ /// which is why `command::conflicts` exists to say so at startup.
+ pub fn match_command(&self, mods: &ModifiersState, key: &Key) -> Option<&'static str> {
+ for (shortcut, command) in &self.bindings {
if shortcut.matches(mods, key) {
- return Some(*action);
+ return Some(command);
}
}
None
}
+
+ /// The chord bound to `command`, for showing beside its label.
+ pub fn chord_for(&self, command: &str) -> Option<&Shortcut> {
+ self.bindings.iter().find(|(_, c)| *c == command).map(|(s, _)| s)
+ }
}
diff --git a/src/window.rs b/src/window.rs
index 3cd256b..8abf9a0 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -76,8 +76,8 @@ impl State {
}
}
- if let Some(action) = state.pending_action.take() {
- state.execute_action(action);
+ if let Some(id) = state.pending_command.take() {
+ state.run_command(id);
changed = true;
}
@@ -593,6 +593,10 @@ impl State {
let res = state.apply_mcp_call(&call, &mut needs_redraw);
let _ = call.reply.send(res);
}
+ CustomEvent::RunCommand(id) => {
+ state.run_command(id);
+ needs_redraw = true;
+ }
CustomEvent::RunAction(action) => {
if let Err(e) = state.apply_action(action, &mut needs_redraw) {
state.update_status_text(&e);
@@ -1036,6 +1040,15 @@ impl State {
Err(format!("unknown menu action label: {}", label.replace(['"', '\\'], "'")))
}
}
+ McpAction::RunCommand { id } => {
+ if state.run_command(&id) {
+ state.sync_nodes();
+ needs_redraw = true;
+ Ok(format!("Command run: {}", id.replace(['"', '\\'], "'")))
+ } else {
+ Err(format!("unknown command: {}", id.replace(['"', '\\'], "'")))
+ }
+ }
McpAction::MenuClosed { widget_idx, menu_idx } => {
if state.active_menu_cloud_idx == Some((widget_idx, menu_idx)) {
state.active_menu_cloud_pid = None;