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 }