git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/command.rs (27.5K)

  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     // --- Images ---
167     // The 2D context's commands (`src/image_tools.rs`). The two camera rows
168     // are the viewport's; the rest make nodes, so they are the network's.
169     Command { id: "frame_image", label: "Frame Image", context: Context::Viewport, run: Run::Key(Action::FrameImage), default_chord: Some("Ctrl+Shift+f") },
170     Command { id: "view_image_pixels", label: "View Image Pixels 1:1", context: Context::Viewport, run: Run::Key(Action::ViewImagePixels), default_chord: None },
171     Command { id: "new_image", label: "New Image", context: Context::Network, run: Run::Key(Action::NewImage), default_chord: None },
172     Command { id: "add_image_rectangle", label: "Add Rectangle to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Rectangle)), default_chord: None },
173     Command { id: "add_image_ellipse", label: "Add Ellipse to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Ellipse)), default_chord: None },
174     Command { id: "add_image_line", label: "Add Line to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Line)), default_chord: None },
175     Command { id: "add_image_polygon", label: "Add Polygon to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Polygon)), default_chord: None },
176     Command { id: "add_image_text", label: "Add Text to Image", context: Context::Network, run: Run::Key(Action::AddToImage(crate::image_tools::ImageLayer::Text)), default_chord: None },
177 
178     // --- Network ---
179     // The add-node palette. Tab opens it inline (like Escape's cascade, and
180     // like `deselect` above, the row ships unbound rather than duplicating a
181     // key the event loop already claims), and it is the first row of the
182     // network's right-click menu, which dispatches through this id.
183     // `b`, as `e` is the geometry flag's: the selection's nodes stay in the
184     // graph and do nothing. Not `toggle_`: that family is the settings'
185     // switches, and this acts on what is selected.
186     Command { id: "bypass_node", label: "Bypass Node", context: Context::Network, run: Run::Key(Action::ToggleBypass), default_chord: Some("b") },
187     Command { id: "rename_node", label: "Rename Node", context: Context::Network, run: Run::Menu("Rename Node"), default_chord: None },
188     Command { id: "group_markers", label: "Group Markers", context: Context::Viewport, run: Run::Menu("Group Markers"), default_chord: None },
189     Command { id: "attribute_visualizers", label: "Attribute Visualizers", context: Context::Viewport, run: Run::Menu("Attribute Visualizers"), default_chord: None },
190     Command { id: "add_node", label: "Add Node", context: Context::Network, run: Run::Menu("Add Node"), default_chord: None },
191     Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
192     Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
193     Command { id: "reset_zoom", label: "Reset Zoom", context: Context::Network, run: Run::Menu("Reset Zoom"), default_chord: None },
194     Command { id: "toggle_params_plate", label: "Parameters Plate", context: Context::Parameters, run: Run::Key(Action::ToggleParamsPlate), default_chord: None },
195     Command { id: "toggle_circular_pane", label: "Circular Pane", context: Context::Network, run: Run::Key(Action::ToggleCircularPane), default_chord: Some("Ctrl+d") },
196     Command { id: "detach_circular_window", label: "Detach Circular Window", context: Context::Network, run: Run::Key(Action::DetachCircularWindow), default_chord: None },
197 
198     // --- Viewer states ---
199     // Ctrl+Shift+H, not Ctrl+H: the ctrl+hjkl family below is the network
200     // pane's view panning, and the conflict check caught the collision the
201     // first time both existed.
202     Command { id: "edit_handles", label: "Edit Handles", context: Context::Viewport, run: Run::Key(Action::ToggleViewerState), default_chord: Some("Ctrl+Shift+h") },
203     Command { id: "toggle_snap", label: "Toggle Snapping", context: Context::Viewport, run: Run::Key(Action::ToggleSnap), default_chord: Some("Ctrl+b") },
204 
205     // --- Viewport ---
206     Command { id: "toggle_grid", label: "Show Grid", context: Context::Viewport, run: Run::Key(Action::ToggleGrid), default_chord: Some("Ctrl+g") },
207     Command { id: "toggle_origin", label: "Show Origin", context: Context::Viewport, run: Run::Key(Action::ToggleOrigin), default_chord: None },
208     Command { id: "toggle_camera_pivot", label: "Show Camera Pivot", context: Context::Viewport, run: Run::Key(Action::ToggleCameraPivot), default_chord: None },
209     Command { id: "toggle_wireframe", label: "Show Wireframe", context: Context::Viewport, run: Run::Key(Action::ToggleWireframe), default_chord: None },
210     Command { id: "toggle_smooth_shading", label: "Smooth Shading", context: Context::Viewport, run: Run::Key(Action::ToggleSmoothShading), default_chord: None },
211     Command { id: "toggle_show_occluded", label: "Show Occluded", context: Context::Viewport, run: Run::Key(Action::ToggleShowOccluded), default_chord: None },
212     // The point overlays on the visible scene. Per-node `meta` child
213     // preferences until 2026-09-23; global display settings now, reached
214     // here like every other viewport toggle.
215     Command { id: "toggle_point_markers", label: "Show Point Markers", context: Context::Viewport, run: Run::Key(Action::TogglePointMarkers), default_chord: None },
216     Command { id: "toggle_point_numbers", label: "Show Point Numbers", context: Context::Viewport, run: Run::Key(Action::TogglePointNumbers), default_chord: None },
217     Command { id: "toggle_point_normals", label: "Show Point Normals", context: Context::Viewport, run: Run::Key(Action::TogglePointNormals), default_chord: None },
218     Command { id: "toggle_prim_numbers", label: "Show Primitive Numbers", context: Context::Viewport, run: Run::Key(Action::TogglePrimNumbers), default_chord: None },
219     Command { id: "toggle_prim_normals", label: "Show Primitive Normals", context: Context::Viewport, run: Run::Key(Action::TogglePrimNormals), default_chord: None },
220     Command { id: "toggle_vertex_markers", label: "Show Vertex Markers", context: Context::Viewport, run: Run::Key(Action::ToggleVertexMarkers), default_chord: None },
221     Command { id: "toggle_vertex_numbers", label: "Show Vertex Numbers", context: Context::Viewport, run: Run::Key(Action::ToggleVertexNumbers), default_chord: None },
222     Command { id: "toggle_vertex_normals", label: "Show Vertex Normals", context: Context::Viewport, run: Run::Key(Action::ToggleVertexNormals), default_chord: None },
223     Command { id: "toggle_wire_single_color", label: "Wireframe Single Color", context: Context::Viewport, run: Run::Key(Action::ToggleWireSingleColor), default_chord: None },
224     Command { id: "toggle_ray_traced_preview", label: "Ray Traced Preview", context: Context::Viewport, run: Run::Key(Action::ToggleRayTracedPreview), default_chord: None },
225     // The viewport menubar's Camera menu, as commands. The cameras
226     // themselves are rows of the palette (`CAMERA_ROW_PREFIX`), being nodes
227     // and not a fixed list.
228     Command { id: "next_camera", label: "Next Camera", context: Context::Viewport, run: Run::Menu("Next Camera"), default_chord: None },
229     Command { id: "previous_camera", label: "Previous Camera", context: Context::Viewport, run: Run::Menu("Previous Camera"), default_chord: None },
230     Command { id: "default_camera", label: "Default Camera", context: Context::Viewport, run: Run::Menu("Default Camera"), default_chord: None },
231     Command { id: "toggle_square_viewport", label: "Square Aspect", context: Context::Viewport, run: Run::Key(Action::ToggleSquareViewport), default_chord: Some("Ctrl+a") },
232 
233     // --- Parameters ---
234     Command { id: "export", label: "Export", context: Context::Parameters, run: Run::Menu("Export"), default_chord: None },
235     // The parameters menubar's Preset and Reset menus, which did one
236     // thing. That bar is not drawn, so until this row it was reachable by
237     // MCP alone.
238     Command { id: "reset_parameters", label: "Reset Parameters", context: Context::Parameters, run: Run::Menu("Reset Parameters"), default_chord: None },
239 
240     // --- Playbar ---
241     Command { id: "play_pause", label: "Play / Pause", context: Context::Playbar, run: Run::Key(Action::PlayPause), default_chord: Some("Up") },
242     Command { id: "play_pause_reverse", label: "Play / Pause Reverse", context: Context::Playbar, run: Run::Key(Action::PlayPauseReverse), default_chord: Some("Down") },
243     Command { id: "frame_next", label: "Next Frame", context: Context::Playbar, run: Run::Key(Action::FrameNext), default_chord: Some("Right") },
244     Command { id: "frame_prev", label: "Previous Frame", context: Context::Playbar, run: Run::Key(Action::FramePrev), default_chord: Some("Left") },
245     // Ctrl+Up rewinds: stops a moving timeline and lands on the start frame,
246     // the way a transport's stop-to-start does — one press whatever the
247     // timeline is doing.
248     Command { id: "frame_start", label: "Go To Start Frame", context: Context::Playbar, run: Run::Key(Action::FrameStart), default_chord: Some("Ctrl+Up") },
249     // Repeat: wrap at the end of the range (on) or stop on the last frame
250     // (off). A setting rather than a transport key, so it ships unbound and
251     // carries a switch in the palette; persisted in state.kdl.
252     Command { id: "toggle_playbar_repeat", label: "Repeat Playback", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarRepeat), default_chord: None },
253     // The Previous / Next Frame buttons either side of the play button: a
254     // switch in the playbar menu and the palette, saved with the settings.
255     Command { id: "toggle_playbar_step_buttons", label: "Show Step Buttons", context: Context::Playbar, run: Run::Key(Action::TogglePlaybarStepButtons), default_chord: None },
256 ];
257 
258 pub fn by_id(id: &str) -> Option<&'static Command> {
259     COMMANDS.iter().find(|c| c.id == id)
260 }
261 
262 /// A chord claimed by more than one command.
263 #[derive(Debug, Clone, PartialEq)]
264 pub struct Conflict {
265     pub chord: String,
266     /// The command that wins — the earlier one in [`COMMANDS`], because
267     /// matching is first-wins over registration order.
268     pub winner: &'static str,
269     /// The commands that are consequently unreachable by this chord.
270     pub shadowed: Vec<&'static str>,
271 }
272 
273 /// Which chords two or more commands claim, given each command's RESOLVED
274 /// chord (the default, or the user's override from `input.kdl`).
275 ///
276 /// Worth detecting because the failure is silent and looks like a broken
277 /// command rather than a broken binding: `match_action` returns the first
278 /// match, so the second command simply never runs and says nothing about why.
279 /// Chords are compared as parsed, not as text, so "Ctrl+S" and "ctrl+s"
280 /// collide the way they actually do at the keyboard.
281 pub fn conflicts(resolved: &[(&'static str, String)]) -> Vec<Conflict> {
282     let mut seen: Vec<(crate::shortcut::Shortcut, String, &'static str, Vec<&'static str>)> =
283         Vec::new();
284     for (id, chord) in resolved {
285         let Ok(parsed) = crate::shortcut::Shortcut::parse(chord) else { continue };
286         match seen.iter_mut().find(|(s, _, _, _)| *s == parsed) {
287             Some((_, _, _, shadowed)) => shadowed.push(id),
288             None => seen.push((parsed, chord.clone(), id, Vec::new())),
289         }
290     }
291     seen.into_iter()
292         .filter(|(_, _, _, shadowed)| !shadowed.is_empty())
293         .map(|(_, chord, winner, shadowed)| Conflict { chord, winner, shadowed })
294         .collect()
295 }
296 
297 /// Rank `haystack` against a fuzzy `query`, returning the matching indices
298 /// best first.
299 ///
300 /// The ranking is the plugin's fuzzyfinder, deliberately: shortest contiguous
301 /// span containing the query's characters in order, then earliest start, then
302 /// alphabetical. Keeping the ranking means muscle memory survives the move —
303 /// typing "sg" has to keep landing on Show Grid.
304 ///
305 /// An empty query matches everything, in registry order, which is what makes
306 /// the palette usable as a plain list of what exists.
307 pub fn fuzzy_rank(query: &str, haystack: &[&str]) -> Vec<usize> {
308     let needle: Vec<char> = query.to_lowercase().chars().filter(|c| !c.is_whitespace()).collect();
309     if needle.is_empty() {
310         return (0..haystack.len()).collect();
311     }
312     let mut scored: Vec<(usize, usize, &str, usize)> = Vec::new();
313     for (i, item) in haystack.iter().enumerate() {
314         let chars: Vec<char> = item.to_lowercase().chars().collect();
315         // The shortest window containing the needle as a subsequence: try each
316         // start, take the first that matches, keep the tightest. The plugin
317         // gets this from an overlapping-match lookahead regex; the loop is the
318         // same answer without the regex engine.
319         let mut best: Option<(usize, usize)> = None;
320         for start in 0..chars.len() {
321             if chars[start] != needle[0] {
322                 continue;
323             }
324             let mut n = 1;
325             let mut end = start + 1;
326             while end < chars.len() && n < needle.len() {
327                 if chars[end] == needle[n] {
328                     n += 1;
329                 }
330                 end += 1;
331             }
332             if n == needle.len() {
333                 let span = end - start;
334                 if best.is_none_or(|(b, _)| span < b) {
335                     best = Some((span, start));
336                 }
337             }
338         }
339         if let Some((span, start)) = best {
340             scored.push((span, start, item, i));
341         }
342     }
343     scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
344     scored.into_iter().map(|(_, _, _, i)| i).collect()
345 }
346 
347 /// One palette row: the label padded to `width`, then its chord.
348 ///
349 /// Padded rather than tab-separated because the popup renders a tab as a
350 /// single literal tab stop, so chords after labels of different lengths do not
351 /// line up into a column.
352 pub fn palette_row(label: &str, chord: Option<String>, width: usize) -> String {
353     match chord {
354         Some(chord) => format!("{label:<width$}{chord}"),
355         None => label.to_string(),
356     }
357 }
358 
359 /// The command a palette row names.
360 ///
361 /// The LONGEST label the row starts with, because labels prefix each other:
362 /// "Save" starts the row that belongs to "Save As", and picking the first
363 /// match would run the wrong command from a padded row.
364 pub fn from_palette_row(row: &str) -> Option<&'static Command> {
365     let row = row.trim_end();
366     COMMANDS.iter().filter(|c| row.starts_with(c.label)).max_by_key(|c| c.label.len())
367 }
368 
369 /// The commands to offer, ranked: `query` decides which, `focused` decides the
370 /// order among equals.
371 pub fn palette_entries(query: &str, focused: Context) -> Vec<&'static Command> {
372     let labels: Vec<&str> = COMMANDS.iter().map(|c| c.label).collect();
373     let contexts: Vec<Context> = COMMANDS.iter().map(|c| c.context).collect();
374     rank_with_focus(query, &labels, &contexts, focused).into_iter().map(|i| &COMMANDS[i]).collect()
375 }
376 
377 /// `fuzzy_rank` over `labels`, then the entries whose context is the
378 /// focused pane's partitioned to the front — stably, so the fuzzy ranking
379 /// survives inside each half, and without dropping anything (a palette that
380 /// hides what you are looking for is worse than one that lists it second).
381 /// The dialog ranks its setting rows alongside the commands through this,
382 /// as `Context::Always` entries.
383 pub fn rank_with_focus(query: &str, labels: &[&str], contexts: &[Context], focused: Context) -> Vec<usize> {
384     let mut ranked = fuzzy_rank(query, labels);
385     if focused != Context::Always {
386         let (mine, rest): (Vec<_>, Vec<_>) = ranked.into_iter().partition(|&i| contexts[i] == focused);
387         ranked = mine;
388         ranked.extend(rest);
389     }
390     ranked
391 }