git.lucas.co / cce-designer
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 }