graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/command.rs (23K)
1 //! The command registry — one list of everything the app can be asked to do.
2 //!
3 //! Before this there were three vocabularies with nothing holding them
4 //! together: the [`Action`] enum matched against chords, the menu-item LABELS
5 //! that `execute_menu_action` dispatches on, and the menubar declarations that
6 //! put those labels on screen. A command existed in whichever of them someone
7 //! had needed at the time, so "Show Origin" was an `Action` with no chord, undo
8 //! was a menu item with no chord, and nothing could tell you either fact.
9 //!
10 //! The plugin this app is replacing learned the same lesson and wrote it down:
11 //! its `hccommands.py` moved the label onto the method as a decorator because
12 //! a `label -> method` map maintained beside the methods drifted from them, and
13 //! a renamed method silently emptied the panel. Rust has no decorators, so the
14 //! equivalent is one static table where each command carries everything about
15 //! itself, plus a test that every chord and every dispatched label names a row
16 //! in it. There is still only one place to forget.
17 //!
18 //! What a registry entry is NOT is a second implementation. [`Run`] names the
19 //! path a command already takes — an `Action` or a menu label — so the palette,
20 //! the chord and the menu all end up in the same code. A command with two ways
21 //! to run it would be two commands that drift.
22
23 use crate::shortcut::Action;
24
25 /// Where a command applies.
26 ///
27 /// The palette ranks by this: commands for the focused pane come first, then
28 /// everything global. It is not a filter — a pane-specific command is still
29 /// reachable from anywhere, because a palette that hides what you are looking
30 /// for is worse than one that lists it second. That is the opposite of the
31 /// plugin's rule, which drops commands a tab cannot run; the difference is that
32 /// there every tab genuinely could not run them, while here every pane exists
33 /// at once and "the viewport's grid" is a thing you may well want from the
34 /// network editor.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub enum Context {
37 Always,
38 Network,
39 Viewport,
40 Parameters,
41 Spreadsheet,
42 Playbar,
43 }
44
45 /// How a command reaches the code that does the work.
46 ///
47 /// Two variants because the app genuinely has two dispatch paths, and
48 /// pretending otherwise would mean rewriting one of them to look like the
49 /// other for no gain. What matters is that a command names exactly one.
50 #[derive(Debug, Clone, Copy, PartialEq)]
51 pub enum Run {
52 /// Through `State::execute_action` — the typed path, and the only one a
53 /// chord could reach before this.
54 Key(Action),
55 /// Through `State::execute_menu_action` by label — the menubar's path.
56 Menu(&'static str),
57 }
58
59 pub struct Command {
60 /// Stable and snake_case: this is what `input.kdl` binds and what a
61 /// conflict report names. It must not change when the label does — the
62 /// case follows that file's existing convention (`zoom_in`,
63 /// `close_window`), since being what the user types there is the id's
64 /// whole job.
65 pub id: &'static str,
66 /// What a human reads, in the palette and in the menus.
67 pub label: &'static str,
68 pub context: Context,
69 pub run: Run,
70 /// The chord this command has when the user has not said otherwise.
71 /// `None` means the command ships unbound and is reachable only through a
72 /// menu or the palette — which is a fine thing to be, but now a visible
73 /// one.
74 pub default_chord: Option<&'static str>,
75 }
76
77 /// Every command, in the order the palette lists ties.
78 ///
79 /// **A `default_chord` of `None` does not always mean unbound.** The toolkit's
80 /// runner claims four chords of its own from `input.kdl`'s `cce-ui` domain
81 /// before an app sees them: `undo` (ctrl+z), `redo` (ctrl+shift+z),
82 /// `focus_next_group` (ctrl+tab) and `focus_prev_group` (ctrl+shift+tab). Undo
83 /// and Redo are listed here with no chord for exactly that reason — they are
84 /// routed to the focused widget FIRST, so a text box undoes its own typing
85 /// before the app is asked, and registering ctrl+z here would take that away
86 /// while looking like a fix. The palette still runs them, which is the gain.
87 ///
88 /// Focus Next/Previous Pane do claim ctrl+tab and ctrl+shift+tab, overriding
89 /// the runner's group-focus chords. That is deliberate and predates this
90 /// registry; it is recorded here because `conflicts()` cannot see it — that
91 /// check compares this app's bindings with each other, not with the toolkit's.
92 pub const COMMANDS: &[Command] = &[
93 // --- File ---
94 Command { id: "new_project", label: "New Project", context: Context::Always, run: Run::Menu("New Project"), default_chord: Some("Ctrl+n") },
95 Command { id: "open_project", label: "Open", context: Context::Always, run: Run::Menu("Open"), default_chord: Some("Ctrl+o") },
96 Command { id: "save_document", label: "Save", context: Context::Always, run: Run::Key(Action::Save), default_chord: Some("Ctrl+s") },
97 Command { id: "save_document_as", label: "Save As", context: Context::Always, run: Run::Key(Action::SaveAs), default_chord: Some("Ctrl+Shift+s") },
98 Command { id: "set_as_default", label: "Set As Default", context: Context::Always, run: Run::Menu("Set As Default"), default_chord: None },
99 Command { id: "exit", label: "Exit", context: Context::Always, run: Run::Menu("Exit"), default_chord: None },
100
101 // --- Edit ---
102 // Chordless on purpose: the runner owns ctrl+z / ctrl+shift+z. See above.
103 Command { id: "undo", label: "Undo", context: Context::Always, run: Run::Key(Action::Undo), default_chord: None },
104 Command { id: "redo", label: "Redo", context: Context::Always, run: Run::Key(Action::Redo), default_chord: None },
105
106 // --- Panes ---
107 Command { id: "show_network_pane", label: "Show Network Pane", context: Context::Always, run: Run::Menu("Show Network Pane"), default_chord: None },
108 Command { id: "show_viewport_pane", label: "Show Viewport Pane", context: Context::Always, run: Run::Menu("Show Viewport Pane"), default_chord: None },
109 Command { id: "show_parameters_pane", label: "Show Parameters Pane", context: Context::Always, run: Run::Menu("Show Parameters Pane"), default_chord: None },
110 Command { id: "toggle_spreadsheet", label: "Show Spreadsheet Pane", context: Context::Always, run: Run::Key(Action::ToggleSpreadsheet), default_chord: Some("`") },
111 Command { id: "show_playbar_pane", label: "Show Playbar Pane", context: Context::Always, run: Run::Menu("Show Playbar Pane"), default_chord: None },
112 Command { id: "close_pane", label: "Close Pane", context: Context::Always, run: Run::Menu("Close Pane"), default_chord: None },
113 Command { id: "next_context", label: "Focus Next Pane", context: Context::Always, run: Run::Key(Action::NextContext), default_chord: Some("Ctrl+Tab") },
114 Command { id: "previous_context", label: "Focus Previous Pane", context: Context::Always, run: Run::Key(Action::PrevContext), default_chord: Some("Ctrl+Shift+Tab") },
115 Command { id: "command_palette", label: "Command Palette", context: Context::Always, run: Run::Key(Action::CommandPalette), default_chord: Some("Ctrl+p") },
116 // Alt+D, not Super+D: the compositor claims every Super chord before any
117 // client sees one (`input.kdl`'s `cce-window-manager` domain binds
118 // super+d to the app launcher), and Super held is also the DE's
119 // window-adjust modifier. Alt is the app's own — the network move family
120 // already lives there.
121 Command { id: "toggle_dialog", label: "Dialog", context: Context::Always, run: Run::Key(Action::ToggleDialog), default_chord: Some("Alt+d") },
122 Command { id: "toggle_configure", label: "Configure", context: Context::Always, run: Run::Key(Action::ToggleConfigure), default_chord: Some("Ctrl+,") },
123
124 // Escape already does this, handled inline with the rest of Escape's
125 // cascade, so the row ships unbound — it is here to be findable in the
126 // palette and bindable by anyone who wants a chord. NOT Ctrl+D, which the
127 // plugin uses for deselect-all but which this app already gives to
128 // Circular Pane.
129 Command { id: "deselect", label: "Deselect", context: Context::Network, run: Run::Key(Action::Deselect), default_chord: None },
130
131 // --- Network navigation ---
132 //
133 // The plugin's scheme, ported: hjkl rather than arrows (the arrows are the
134 // playbar transport in every pane), bare to move the cursor, alt to move
135 // the node under it, ctrl to pan the view. `shift+hjkl` — extend the
136 // selection — is deliberately absent: the Graph widget carries a single
137 // `selected_node`, and a select family with nothing to extend would be
138 // four rows that quietly do what bare hjkl already does.
139 Command { id: "nav_left", label: "Cursor Left", context: Context::Network, run: Run::Key(Action::NetworkNav(-1, 0)), default_chord: Some("h") },
140 Command { id: "nav_down", label: "Cursor Down", context: Context::Network, run: Run::Key(Action::NetworkNav(0, 1)), default_chord: Some("j") },
141 Command { id: "nav_up", label: "Cursor Up", context: Context::Network, run: Run::Key(Action::NetworkNav(0, -1)), default_chord: Some("k") },
142 Command { id: "nav_right", label: "Cursor Right", context: Context::Network, run: Run::Key(Action::NetworkNav(1, 0)), default_chord: Some("l") },
143 // shift+hjkl — the plugin's extend-the-selection family. It was absent
144 // while the graph's single `selected_node` was the whole selection; the
145 // cursor is a REGION now, so these grow its far corner and the nodes
146 // inside it are the selection (see `State::selected_slots`).
147 Command { id: "extend_left", label: "Extend Selection Left", context: Context::Network, run: Run::Key(Action::NetworkExtend(-1, 0)), default_chord: Some("Shift+h") },
148 Command { id: "extend_down", label: "Extend Selection Down", context: Context::Network, run: Run::Key(Action::NetworkExtend(0, 1)), default_chord: Some("Shift+j") },
149 Command { id: "extend_up", label: "Extend Selection Up", context: Context::Network, run: Run::Key(Action::NetworkExtend(0, -1)), default_chord: Some("Shift+k") },
150 Command { id: "extend_right", label: "Extend Selection Right", context: Context::Network, run: Run::Key(Action::NetworkExtend(1, 0)), default_chord: Some("Shift+l") },
151 Command { id: "move_left", label: "Move Node Left", context: Context::Network, run: Run::Key(Action::NetworkMove(-1, 0)), default_chord: Some("Alt+h") },
152 Command { id: "move_down", label: "Move Node Down", context: Context::Network, run: Run::Key(Action::NetworkMove(0, 1)), default_chord: Some("Alt+j") },
153 Command { id: "move_up", label: "Move Node Up", context: Context::Network, run: Run::Key(Action::NetworkMove(0, -1)), default_chord: Some("Alt+k") },
154 Command { id: "move_right", label: "Move Node Right", context: Context::Network, run: Run::Key(Action::NetworkMove(1, 0)), default_chord: Some("Alt+l") },
155 Command { id: "view_left", label: "Pan View Left", context: Context::Network, run: Run::Key(Action::NetworkPan(-1, 0)), default_chord: Some("Ctrl+h") },
156 Command { id: "view_down", label: "Pan View Down", context: Context::Network, run: Run::Key(Action::NetworkPan(0, 1)), default_chord: Some("Ctrl+j") },
157 Command { id: "view_up", label: "Pan View Up", context: Context::Network, run: Run::Key(Action::NetworkPan(0, -1)), default_chord: Some("Ctrl+k") },
158 Command { id: "view_right", label: "Pan View Right", context: Context::Network, run: Run::Key(Action::NetworkPan(1, 0)), default_chord: Some("Ctrl+l") },
159 Command { id: "frame_cursor", label: "Frame Cursor", context: Context::Network, run: Run::Key(Action::FrameCursor), default_chord: Some("f") },
160 // Ctrl+Shift+L rather than the L that Houdini uses: bare hjkl is the
161 // cursor, and shift+hjkl is reserved for the select family this app cannot
162 // implement yet — taking Shift+L now would have to be given back later.
163 Command { id: "layout_nodes", label: "Layout Nodes", context: Context::Network, run: Run::Key(Action::LayoutNodes), default_chord: Some("Ctrl+Shift+l") },
164 Command { id: "frame_all", label: "Frame All", context: Context::Network, run: Run::Key(Action::FrameAll), default_chord: Some("Shift+f") },
165
166 // --- Network ---
167 // The add-node palette. Tab opens it inline (like Escape's cascade, and
168 // like `deselect` above, the row ships unbound rather than duplicating a
169 // key the event loop already claims), and it is the first row of the
170 // network's right-click menu, which dispatches through this id.
171 Command { id: "add_node", label: "Add Node", context: Context::Network, run: Run::Menu("Add Node"), default_chord: None },
172 Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
173 Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
174 Command { id: "reset_zoom", label: "Reset Zoom", context: Context::Network, run: Run::Menu("Reset Zoom"), default_chord: None },
175 Command { id: "toggle_network_plate", label: "Network Plate", context: Context::Network, run: Run::Key(Action::ToggleNetworkPlate), default_chord: Some("Shift+p") },
176 Command { id: "toggle_circular_pane", label: "Circular Pane", context: Context::Network, run: Run::Key(Action::ToggleCircularPane), default_chord: Some("Ctrl+d") },
177 Command { id: "detach_circular_window", label: "Detach Circular Window", context: Context::Network, run: Run::Key(Action::DetachCircularWindow), default_chord: None },
178
179 // --- Viewer states ---
180 // Ctrl+Shift+H, not Ctrl+H: the ctrl+hjkl family below is the network
181 // pane's view panning, and the conflict check caught the collision the
182 // first time both existed.
183 Command { id: "edit_handles", label: "Edit Handles", context: Context::Viewport, run: Run::Key(Action::ToggleViewerState), default_chord: Some("Ctrl+Shift+h") },
184 Command { id: "toggle_snap", label: "Toggle Snapping", context: Context::Viewport, run: Run::Key(Action::ToggleSnap), default_chord: Some("Ctrl+b") },
185
186 // --- Viewport ---
187 Command { id: "toggle_grid", label: "Show Grid", context: Context::Viewport, run: Run::Key(Action::ToggleGrid), default_chord: Some("Ctrl+g") },
188 Command { id: "toggle_cube", label: "Show Cube", context: Context::Viewport, run: Run::Key(Action::ToggleCube), default_chord: Some("Ctrl+e") },
189 Command { id: "toggle_origin", label: "Show Origin", context: Context::Viewport, run: Run::Key(Action::ToggleOrigin), default_chord: None },
190 Command { id: "toggle_camera_pivot", label: "Show Camera Pivot", context: Context::Viewport, run: Run::Key(Action::ToggleCameraPivot), default_chord: None },
191 Command { id: "toggle_wireframe", label: "Show Wireframe", context: Context::Viewport, run: Run::Key(Action::ToggleWireframe), default_chord: None },
192 Command { id: "toggle_smooth_shading", label: "Smooth Shading", context: Context::Viewport, run: Run::Key(Action::ToggleSmoothShading), default_chord: None },
193 // The point overlays on the visible scene. Per-node `meta` child
194 // preferences until 2026-09-23; global display settings now, reached
195 // here like every other viewport toggle.
196 Command { id: "toggle_point_markers", label: "Show Point Markers", context: Context::Viewport, run: Run::Key(Action::TogglePointMarkers), default_chord: None },
197 Command { id: "toggle_point_numbers", label: "Show Point Numbers", context: Context::Viewport, run: Run::Key(Action::TogglePointNumbers), default_chord: None },
198 Command { id: "toggle_point_normals", label: "Show Point Normals", context: Context::Viewport, run: Run::Key(Action::TogglePointNormals), default_chord: None },
199 Command { id: "toggle_render_points", label: "Show Points", context: Context::Viewport, run: Run::Key(Action::ToggleRenderPoints), default_chord: None },
200 Command { id: "toggle_wire_single_color", label: "Wireframe Single Color", context: Context::Viewport, run: Run::Key(Action::ToggleWireSingleColor), default_chord: None },
201 Command { id: "toggle_ray_traced_preview", label: "Ray Traced Preview", context: Context::Viewport, run: Run::Key(Action::ToggleRayTracedPreview), default_chord: None },
202 Command { id: "toggle_square_viewport", label: "Square Aspect", context: Context::Viewport, run: Run::Key(Action::ToggleSquareViewport), default_chord: Some("Ctrl+a") },
203
204 // --- Parameters ---
205 Command { id: "export", label: "Export", context: Context::Parameters, run: Run::Menu("Export"), default_chord: None },
206
207 // --- Playbar ---
208 Command { id: "play_pause", label: "Play / Pause", context: Context::Playbar, run: Run::Key(Action::PlayPause), default_chord: Some("Up") },
209 Command { id: "play_pause_reverse", label: "Play / Pause Reverse", context: Context::Playbar, run: Run::Key(Action::PlayPauseReverse), default_chord: Some("Down") },
210 Command { id: "frame_next", label: "Next Frame", context: Context::Playbar, run: Run::Key(Action::FrameNext), default_chord: Some("Right") },
211 Command { id: "frame_prev", label: "Previous Frame", context: Context::Playbar, run: Run::Key(Action::FramePrev), default_chord: Some("Left") },
212 // Ctrl+Up rewinds: stops a moving timeline and lands on the start frame,
213 // the way a transport's stop-to-start does — one press whatever the
214 // timeline is doing.
215 Command { id: "frame_start", label: "Go To Start Frame", context: Context::Playbar, run: Run::Key(Action::FrameStart), default_chord: Some("Ctrl+Up") },
216 ];
217
218 pub fn by_id(id: &str) -> Option<&'static Command> {
219 COMMANDS.iter().find(|c| c.id == id)
220 }
221
222 /// A chord claimed by more than one command.
223 #[derive(Debug, Clone, PartialEq)]
224 pub struct Conflict {
225 pub chord: String,
226 /// The command that wins — the earlier one in [`COMMANDS`], because
227 /// matching is first-wins over registration order.
228 pub winner: &'static str,
229 /// The commands that are consequently unreachable by this chord.
230 pub shadowed: Vec<&'static str>,
231 }
232
233 /// Which chords two or more commands claim, given each command's RESOLVED
234 /// chord (the default, or the user's override from `input.kdl`).
235 ///
236 /// Worth detecting because the failure is silent and looks like a broken
237 /// command rather than a broken binding: `match_action` returns the first
238 /// match, so the second command simply never runs and says nothing about why.
239 /// Chords are compared as parsed, not as text, so "Ctrl+S" and "ctrl+s"
240 /// collide the way they actually do at the keyboard.
241 pub fn conflicts(resolved: &[(&'static str, String)]) -> Vec<Conflict> {
242 let mut seen: Vec<(crate::shortcut::Shortcut, String, &'static str, Vec<&'static str>)> =
243 Vec::new();
244 for (id, chord) in resolved {
245 let Ok(parsed) = crate::shortcut::Shortcut::parse(chord) else { continue };
246 match seen.iter_mut().find(|(s, _, _, _)| *s == parsed) {
247 Some((_, _, _, shadowed)) => shadowed.push(id),
248 None => seen.push((parsed, chord.clone(), id, Vec::new())),
249 }
250 }
251 seen.into_iter()
252 .filter(|(_, _, _, shadowed)| !shadowed.is_empty())
253 .map(|(_, chord, winner, shadowed)| Conflict { chord, winner, shadowed })
254 .collect()
255 }
256
257 /// Rank `haystack` against a fuzzy `query`, returning the matching indices
258 /// best first.
259 ///
260 /// The ranking is the plugin's fuzzyfinder, deliberately: shortest contiguous
261 /// span containing the query's characters in order, then earliest start, then
262 /// alphabetical. Keeping the ranking means muscle memory survives the move —
263 /// typing "sg" has to keep landing on Show Grid.
264 ///
265 /// An empty query matches everything, in registry order, which is what makes
266 /// the palette usable as a plain list of what exists.
267 pub fn fuzzy_rank(query: &str, haystack: &[&str]) -> Vec<usize> {
268 let needle: Vec<char> = query.to_lowercase().chars().filter(|c| !c.is_whitespace()).collect();
269 if needle.is_empty() {
270 return (0..haystack.len()).collect();
271 }
272 let mut scored: Vec<(usize, usize, &str, usize)> = Vec::new();
273 for (i, item) in haystack.iter().enumerate() {
274 let chars: Vec<char> = item.to_lowercase().chars().collect();
275 // The shortest window containing the needle as a subsequence: try each
276 // start, take the first that matches, keep the tightest. The plugin
277 // gets this from an overlapping-match lookahead regex; the loop is the
278 // same answer without the regex engine.
279 let mut best: Option<(usize, usize)> = None;
280 for start in 0..chars.len() {
281 if chars[start] != needle[0] {
282 continue;
283 }
284 let mut n = 1;
285 let mut end = start + 1;
286 while end < chars.len() && n < needle.len() {
287 if chars[end] == needle[n] {
288 n += 1;
289 }
290 end += 1;
291 }
292 if n == needle.len() {
293 let span = end - start;
294 if best.is_none_or(|(b, _)| span < b) {
295 best = Some((span, start));
296 }
297 }
298 }
299 if let Some((span, start)) = best {
300 scored.push((span, start, item, i));
301 }
302 }
303 scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
304 scored.into_iter().map(|(_, _, _, i)| i).collect()
305 }
306
307 /// One palette row: the label padded to `width`, then its chord.
308 ///
309 /// Padded rather than tab-separated because the popup renders a tab as a
310 /// single literal tab stop, so chords after labels of different lengths do not
311 /// line up into a column.
312 pub fn palette_row(label: &str, chord: Option<String>, width: usize) -> String {
313 match chord {
314 Some(chord) => format!("{label:<width$}{chord}"),
315 None => label.to_string(),
316 }
317 }
318
319 /// The command a palette row names.
320 ///
321 /// The LONGEST label the row starts with, because labels prefix each other:
322 /// "Save" starts the row that belongs to "Save As", and picking the first
323 /// match would run the wrong command from a padded row.
324 pub fn from_palette_row(row: &str) -> Option<&'static Command> {
325 let row = row.trim_end();
326 COMMANDS.iter().filter(|c| row.starts_with(c.label)).max_by_key(|c| c.label.len())
327 }
328
329 /// The commands to offer, ranked: `query` decides which, `focused` decides the
330 /// order among equals.
331 pub fn palette_entries(query: &str, focused: Context) -> Vec<&'static Command> {
332 let labels: Vec<&str> = COMMANDS.iter().map(|c| c.label).collect();
333 let contexts: Vec<Context> = COMMANDS.iter().map(|c| c.context).collect();
334 rank_with_focus(query, &labels, &contexts, focused).into_iter().map(|i| &COMMANDS[i]).collect()
335 }
336
337 /// `fuzzy_rank` over `labels`, then the entries whose context is the
338 /// focused pane's partitioned to the front — stably, so the fuzzy ranking
339 /// survives inside each half, and without dropping anything (a palette that
340 /// hides what you are looking for is worse than one that lists it second).
341 /// The dialog ranks its setting rows alongside the commands through this,
342 /// as `Context::Always` entries.
343 pub fn rank_with_focus(query: &str, labels: &[&str], contexts: &[Context], focused: Context) -> Vec<usize> {
344 let mut ranked = fuzzy_rank(query, labels);
345 if focused != Context::Always {
346 let (mine, rest): (Vec<_>, Vec<_>) = ranked.into_iter().partition(|&i| contexts[i] == focused);
347 ranked = mine;
348 ranked.extend(rest);
349 }
350 ranked
351 }