teksilo_widgets/command_palette.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! CommandPalette — type-to-run access to every command an app has registered.
5//!
6//! The palette is **application-agnostic**: it holds no list of its own and knows
7//! nothing about any particular app. Its content is the tree's
8//! [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry), which already
9//! carries everything a palette row needs — a localized
10//! [`name`](teksilo_core::shortcut::Shortcut), an optional `category` to group by, an
11//! optional `description`, the effective keystroke (user rebinds merged in), and a
12//! live `enabled` verdict. Activating a row sends the command's intent, which is the
13//! same path a menu row or the chord itself takes.
14//!
15//! That has a consequence worth stating plainly, because it is the whole design:
16//! **a command does not need a keystroke to appear here.** `iter_effective()` yields
17//! every registered entry, bound or not, so an app makes a command searchable by
18//! registering it with a name and no chord:
19//!
20//! ```ignore
21//! // Reachable from the palette, and rebindable by the user later, without
22//! // occupying a keystroke today.
23//! ctx.register_shortcut_global(
24//! Shortcut::new("document.export")
25//! .name("Export…")
26//! .category("File")
27//! .build(),
28//! );
29//! ```
30//!
31//! # Presenting it
32//!
33//! [`CommandPalette::present`] shows it centered, dismissed by Escape or a click
34//! outside:
35//!
36//! ```ignore
37//! ctx.register_action_global(Action::new("app.command_palette").on_invoke(|_, ctx| {
38//! CommandPalette::new().present(ctx);
39//! }));
40//! ```
41//!
42//! Presenting it as a **window-level** modal is deliberate, not incidental: a palette
43//! is routinely opened from a menu, and a menu is itself a transient overlay.
44//! Anchoring to the invoking widget would render the palette inside the menu that
45//! opened it, positioned against a surface that is about to disappear.
46//!
47//! # Matching
48//!
49//! Typing filters by subsequence, not substring, so `ndw` finds "New Window" and
50//! `expdoc` finds "Export document". Matches score higher when the typed letters land
51//! consecutively and on word starts, so the most literal reading of a query sorts
52//! first. An empty query lists everything in the registry's own deterministic
53//! `(category, id)` order. The category takes part in matching, so `file new` finds
54//! the New command filed under File.
55//!
56//! # Keyboard
57//!
58//! Focus stays in the search field throughout — that is what makes a palette feel
59//! like one. Arrow keys are not editing keys for the field, so they bubble to the
60//! palette's own handler, which moves the highlight and scrolls it into view. Enter
61//! runs the highlighted command; Escape dismisses.
62
63use std::cell::RefCell;
64use std::rc::Rc;
65
66use teksilo_canvas::{Point, Rect, Size, SizeProposal};
67use teksilo_core::accessibility::AccessNodeBuilder;
68use teksilo_core::accesskit::Role;
69use teksilo_core::binding::BindingLevel;
70use teksilo_core::build_context::BuildContext;
71use teksilo_core::color_prop::ColorProp;
72use teksilo_core::event::{EventResponse, Key, WidgetEvent};
73use teksilo_core::intent::Intent;
74use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
75use teksilo_core::shortcut::KeyStroke;
76use teksilo_core::signal::Signal;
77use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
78use teksilo_core::widget_builder::WidgetBuilder;
79use teksilo_core::widget_id::WidgetId;
80use teksilo_data::{ListModel, SelectionMode, SelectionModel};
81use teksilo_i18n::{LocalizedString, lit, tr_widget};
82use teksilo_tokens::{BorderRole, SurfaceRole, TextRole, TextStyleRole};
83
84use crate::dialog::ModalContainer;
85use crate::keystroke_format::format_keystroke;
86use crate::list_view::ListView;
87use crate::primitives::{
88 Expand, FixedSize, HStack, Padding, RectWidget, Spacer, TextWidget, VStack, ZStack,
89};
90use crate::search_field::SearchField;
91
92/// Presented size. Wide enough for a command name plus its chord without either
93/// having to ellipsize in the common case.
94const PALETTE_WIDTH: u32 = 560;
95const PALETTE_HEIGHT: u32 = 420;
96/// Row height fed to the list's own metrics; two lines of text plus padding.
97const ROW_HEIGHT: f32 = 44.0;
98/// Width of the leading bar marking the highlighted row — the non-colour half
99/// of the highlight. 3 dp matches the selection edge `StandardListItem` draws.
100const SELECTION_MARKER_WIDTH: f32 = 3.0;
101/// How many rows the presented palette shows at once.
102///
103/// Derived from [`PALETTE_HEIGHT`] rather than measured, which is what lets the
104/// keyboard scroll be computed without waiting on layout — the palette presents
105/// itself at a fixed size, so the number is known. A caller embedding the palette in
106/// a taller surface still scrolls correctly by pointer and still selects correctly by
107/// keyboard; only the auto-scroll may leave the highlight a row from the edge.
108const VISIBLE_ROWS: usize = 7;
109
110/// One command as the palette sees it.
111///
112/// A read-only projection of a registered shortcut, handed to
113/// [`CommandPalette::include`] so an app can decide what belongs in its palette
114/// without the widget growing knowledge of any app's command names. Deliberately
115/// *not* the `Shortcut` itself: that type carries the activation closure and the
116/// rebinding machinery, neither of which a filter predicate has any business
117/// reaching.
118#[derive(Debug, Clone)]
119pub struct PaletteCommand {
120 /// The stable registry id, e.g. `"work.export"`.
121 pub id: &'static str,
122 /// The localized display name, already resolved for the active locale.
123 pub name: String,
124 /// The grouping label, if the command declared one.
125 pub category: Option<&'static str>,
126 /// The longer explanation, if the command declared one.
127 pub description: Option<String>,
128 /// The effective primary chord — user rebinds merged in — or `None` when the
129 /// command has no keystroke at all, which is normal for a palette-only command.
130 pub keystroke: Option<KeyStroke>,
131 /// Whether the command's own `enabled_when` predicate currently says yes.
132 pub enabled: bool,
133 /// The intent name activation sends. Falls back to [`Self::id`] when the command
134 /// declared no explicit intent, exactly as the keystroke dispatcher does.
135 pub intent: &'static str,
136}
137
138impl PaletteCommand {
139 /// The text a query is matched against: category and name together, so
140 /// `file new` finds a New command filed under File.
141 fn haystack(&self) -> String {
142 match self.category {
143 Some(cat) => format!("{cat} {}", self.name),
144 None => self.name.clone(),
145 }
146 }
147}
148
149type IncludeFn = Rc<dyn Fn(&PaletteCommand) -> bool>;
150type DismissFn = Rc<dyn Fn(&mut EventContext)>;
151
152/// The parts of a palette its event closures need, separated from the widget so they
153/// can be cloned into `'static` handlers without cloning the widget itself.
154#[derive(Clone)]
155struct PaletteState {
156 query: Signal<String>,
157 selected: Signal<usize>,
158 /// The rows currently on screen. The key handler acts on exactly what the reader
159 /// is looking at rather than re-deriving the list and risking a different answer.
160 rows: Rc<RefCell<Vec<PaletteCommand>>>,
161 /// First row currently scrolled into view.
162 ///
163 /// Tracked here rather than read back off the list because the list is rebuilt
164 /// from scratch on every keystroke — a scroll offset living on the widget would
165 /// reset to the top each time the reader typed a letter.
166 top_index: Signal<usize>,
167 /// The query as of the last build, so a *changed* query can reset the highlight
168 /// to the best match without an effect that would fire mid-build.
169 last_query: Rc<RefCell<String>>,
170 on_dismiss: Rc<RefCell<Option<DismissFn>>>,
171
172 // ── Accessibility ───────────────────────────────────────────────────
173 /// The result list's selection, mirroring [`Self::selected`].
174 ///
175 /// The highlight is `selected`; this exists so each realized row's
176 /// `Role::ListItem` reports `selected` truthfully. Without it every row
177 /// answered "not selected" and the arrow keys moved a highlight no
178 /// assistive technology could observe. Owned by the state, not rebuilt per
179 /// build, so a pointer click on a row can be routed back into `selected`.
180 selection: SelectionModel,
181 /// The result `ListView`'s node, published for the search field's
182 /// `controls` relation.
183 listbox_id: Signal<Option<WidgetId>>,
184 /// The highlighted row's node, published for the search field's
185 /// `active_descendant`. `None` when the list is empty, or when the
186 /// highlighted row is outside the realized virtualization window.
187 active_row: Signal<Option<WidgetId>>,
188}
189
190impl PaletteState {
191 fn new() -> Self {
192 Self {
193 query: Signal::new(String::new()),
194 selected: Signal::new(0),
195 rows: Rc::new(RefCell::new(Vec::new())),
196 top_index: Signal::new(0),
197 last_query: Rc::new(RefCell::new(String::new())),
198 on_dismiss: Rc::new(RefCell::new(None)),
199 selection: SelectionModel::new(SelectionMode::Single),
200 listbox_id: Signal::new(None),
201 active_row: Signal::new(None),
202 }
203 }
204
205 /// Move the highlight to `index`, keeping the AT-visible selection with it.
206 ///
207 /// Every write to `selected` goes through here. The two must not drift:
208 /// `selected` is what Enter runs and what the row tint follows, while
209 /// `selection` is what a screen reader is told, and a palette that
210 /// announces one row while running another is worse than one that
211 /// announces nothing.
212 fn set_selected(&self, index: usize) {
213 self.selected.set(index);
214 self.selection.select(index);
215 }
216
217 /// Move the highlight by `delta`, clamped to the list, and scroll it into view.
218 fn step_selection(&self, delta: isize) {
219 let len = self.rows.borrow().len();
220 if len == 0 {
221 return;
222 }
223 let current = self.selected.get() as isize;
224 let next = (current + delta).clamp(0, len as isize - 1) as usize;
225 self.set_selected(next);
226 self.reveal(next);
227 }
228
229 /// Scroll the minimum distance that brings row `index` into view.
230 fn reveal(&self, index: usize) {
231 let top = self.top_index.get();
232 let new_top = if index < top {
233 index
234 } else if index >= top + VISIBLE_ROWS {
235 index + 1 - VISIBLE_ROWS
236 } else {
237 top
238 };
239 if new_top != top {
240 self.top_index.set(new_top);
241 }
242 }
243
244 /// Send the highlighted command's intent, then dismiss.
245 ///
246 /// The intent is synthesized from the command's declared name, which is what the
247 /// dispatcher sends for a chord with no custom activation closure — so a command
248 /// reached from the palette and the same command reached from its keystroke
249 /// arrive at the identical action.
250 fn activate_selected(&self, ctx: &mut EventContext) {
251 let picked = {
252 let rows = self.rows.borrow();
253 rows.get(self.selected.get()).cloned()
254 };
255 let Some(cmd) = picked else { return };
256 if !cmd.enabled {
257 // Reachable only with `show_disabled`, where a greyed row is displayed
258 // precisely to say "not now" — running it anyway would make the grey a lie.
259 return;
260 }
261 ctx.send_intent(Intent::new(cmd.intent));
262 let dismiss = self.on_dismiss.borrow().clone();
263 if let Some(dismiss) = dismiss {
264 dismiss(ctx);
265 }
266 }
267
268 fn dismiss(&self, ctx: &mut EventContext) {
269 let dismiss = self.on_dismiss.borrow().clone();
270 if let Some(dismiss) = dismiss {
271 dismiss(ctx);
272 }
273 }
274}
275
276/// Type-to-run access to every registered command. See the [module docs](self).
277pub struct CommandPalette {
278 state: PaletteState,
279 placeholder: Option<LocalizedString>,
280 empty_text: Option<LocalizedString>,
281 include: Option<IncludeFn>,
282 show_disabled: bool,
283 root_child_id: Option<WidgetId>,
284}
285
286impl Default for CommandPalette {
287 fn default() -> Self {
288 Self::new()
289 }
290}
291
292impl CommandPalette {
293 /// A palette over every command in the tree's registry.
294 pub fn new() -> Self {
295 Self {
296 state: PaletteState::new(),
297 placeholder: None,
298 empty_text: None,
299 include: None,
300 show_disabled: false,
301 root_child_id: None,
302 }
303 }
304
305 /// Replace the search field's placeholder text.
306 pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
307 self.placeholder = Some(text.into());
308 self
309 }
310
311 /// Replace the text shown when nothing matches the query.
312 pub fn empty_text(mut self, text: impl Into<LocalizedString>) -> Self {
313 self.empty_text = Some(text.into());
314 self
315 }
316
317 /// Keep only the commands this predicate accepts.
318 ///
319 /// The usual reasons are to hide the command that opens the palette itself, and
320 /// to drop registry entries that are key bindings rather than commands a person
321 /// would look for by name.
322 pub fn include(mut self, f: impl Fn(&PaletteCommand) -> bool + 'static) -> Self {
323 self.include = Some(Rc::new(f));
324 self
325 }
326
327 /// Run this after a command is activated, and when Escape is pressed.
328 ///
329 /// [`present`](Self::present) installs its own, so this is for callers embedding
330 /// the palette in a surface they manage themselves.
331 pub fn on_dismiss(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
332 *self.state.on_dismiss.borrow_mut() = Some(Rc::new(f));
333 self
334 }
335
336 /// Also list commands whose `enabled_when` predicate currently says no, greyed
337 /// out and inert. Off by default: a palette answers "what can I do now", and a
338 /// row that cannot run is a row that has to be explained.
339 pub fn show_disabled(mut self, show: bool) -> Self {
340 self.show_disabled = show;
341 self
342 }
343
344 /// The query signal, so a caller can seed or observe what was typed.
345 pub fn query_signal(&self) -> Signal<String> {
346 self.state.query.clone()
347 }
348
349 /// Show the palette centered in the window, dismissed by Escape or a click
350 /// outside. See the [module docs](self) on why this is window-level.
351 pub fn present(self, ctx: &mut EventContext) {
352 let palette = if self.state.on_dismiss.borrow().is_none() {
353 self.on_dismiss(|ctx| ctx.dismiss_modal())
354 } else {
355 self
356 };
357 let mut inner = Some(palette);
358 ctx.present_modal(
359 ModalRequest::deferred(move |tree| {
360 let palette = inner
361 .take()
362 .expect("CommandPalette present closure called twice");
363 tree.add(ModalContainer::new(palette))
364 })
365 .presentation(ModalPresentation::InTree)
366 .close_behavior(ModalCloseBehavior::EscapeOrClickOutside)
367 .size(PALETTE_WIDTH, PALETTE_HEIGHT),
368 );
369 }
370
371 /// Read the registry, apply `include`, match against the query, and rank.
372 fn visible_rows(&self, ctx: &BuildContext) -> Vec<PaletteCommand> {
373 let needle = self.state.query.get().trim().to_lowercase();
374 let mut scored: Vec<(i32, PaletteCommand)> = ctx
375 .shortcut_registry()
376 .iter_effective()
377 .map(|eff| PaletteCommand {
378 id: eff.shortcut.id,
379 name: eff.shortcut.name.get(),
380 category: eff.shortcut.category,
381 description: eff.shortcut.description.as_ref().map(|d| d.get()),
382 keystroke: eff.primary,
383 enabled: eff.enabled,
384 intent: eff.shortcut.intent_name(),
385 })
386 .filter(|cmd| self.show_disabled || cmd.enabled)
387 .filter(|cmd| self.include.as_ref().is_none_or(|f| f(cmd)))
388 .filter_map(|cmd| Some((fuzzy_score(&needle, &cmd.haystack())?, cmd)))
389 .collect();
390 // Highest score first. `iter_effective` already ordered by (category, id) and
391 // `sort_by` is stable, so equal scores — which is every row when the query is
392 // empty — keep exactly that order.
393 scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
394 scored.into_iter().map(|(_, cmd)| cmd).collect()
395 }
396}
397
398impl std::fmt::Debug for CommandPalette {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 f.debug_struct("CommandPalette")
401 .field("query", &self.state.query.get())
402 .field("rows", &self.state.rows.borrow().len())
403 .finish_non_exhaustive()
404 }
405}
406
407impl Widget for CommandPalette {
408 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
409 // Any registry change — a command registered, rebound, enabled — changes what
410 // the palette should be showing.
411 ctx.shortcut_version().bind_to(
412 ctx.self_id(),
413 ctx.binding_registry(),
414 BindingLevel::Rebuild,
415 );
416 self.state
417 .query
418 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
419 self.state
420 .selected
421 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
422 self.state
423 .top_index
424 .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
425
426 let rows = self.visible_rows(ctx);
427 // A changed query means a different list: send the highlight back to the best
428 // match rather than leaving it on whatever now happens to sit at that index.
429 let query_now = self.state.query.get();
430 if *self.state.last_query.borrow() != query_now {
431 *self.state.last_query.borrow_mut() = query_now;
432 self.state.set_selected(0);
433 self.state.top_index.set(0);
434 }
435 // Clamp before rendering, not after: a query that shortened the list must not
436 // leave the highlight past the end for even one frame, or Enter would run
437 // whichever row happens to sit at a stale index.
438 if self.state.selected.get() >= rows.len() {
439 self.state.set_selected(rows.len().saturating_sub(1));
440 }
441 // Keep the AT-visible selection on the highlight even when neither
442 // branch above fired (first build, or a rebuild driven by the shortcut
443 // registry rather than by a keystroke).
444 if !rows.is_empty() && !self.state.selection.is_selected(self.state.selected.get()) {
445 self.state.selection.select(self.state.selected.get());
446 }
447 *self.state.rows.borrow_mut() = rows.clone();
448 let selected_index = self.state.selected.get();
449
450 let placeholder = self
451 .placeholder
452 .clone()
453 .unwrap_or_else(|| tr_widget!(command_palette_placeholder()));
454
455 let submit_state = self.state.clone();
456 let field = SearchField::new(self.state.query.clone())
457 .placeholder(placeholder)
458 .label(tr_widget!(command_palette_title()))
459 .on_submit_fn(move |ctx| submit_state.activate_selected(ctx))
460 // The ARIA combobox pattern. Focus never leaves this field — that
461 // is what makes a palette feel like one — so the arrow-key
462 // highlight has to be announced through the field's own AT node.
463 // `SearchField` forwards both down to the focusable
464 // `TextInputField`, the only node whose `active_descendant`
465 // assistive technology follows.
466 .drives_listbox(self.state.listbox_id.clone(), self.state.active_row.clone());
467
468 // Stale entries would otherwise survive an empty result set and point
469 // `active_descendant` at a destroyed node.
470 self.state.listbox_id.set(None);
471 self.state.active_row.set(None);
472
473 let body: Box<dyn Widget> = if rows.is_empty() {
474 let empty = self
475 .empty_text
476 .clone()
477 .unwrap_or_else(|| tr_widget!(command_palette_empty()));
478 Box::new(
479 Padding::symmetric(14.0, 12.0).child(
480 TextWidget::new(empty)
481 .style(TextStyleRole::Body)
482 .color(TextRole::Secondary),
483 ),
484 )
485 } else {
486 let list = ListView::new(
487 ListModel::from_vec(rows),
488 move |index, cmd: &PaletteCommand, _row_selected| {
489 Box::new(command_row(cmd, index == selected_index))
490 },
491 )
492 .item_height(ROW_HEIGHT)
493 // Makes each row's `Role::ListItem` report `selected` truthfully.
494 .selection(self.state.selection.clone());
495 // Take the realized-row map before the view moves into the tree.
496 let row_ids = list.realized_row_ids();
497 // The window the reader is looking at is state this widget owns, so it
498 // survives the rebuild that every keystroke causes.
499 list.scroll_to_index(self.state.top_index.get());
500
501 // `ctx.add` builds the subtree synchronously, so by the time this
502 // returns the body pane has already published its realized rows and
503 // the highlighted row's id is resolvable — no deferred effect, no
504 // frame of silence after an arrow key.
505 let list_id = ctx.add(list);
506 self.state.listbox_id.set(Some(list_id));
507 let active = row_ids
508 .borrow()
509 .iter()
510 .find(|(index, _)| *index == selected_index)
511 .map(|(_, id)| *id);
512 self.state.active_row.set(active);
513
514 Box::new(Expand::new().child_id(list_id))
515 };
516
517 let key_state = self.state.clone();
518 // The column is pinned to the presented size rather than left to size itself.
519 // `ModalContainer` sizes to its content, and the result list lives under an
520 // `Expand` — with no bounded height to fill, the list measures zero and the
521 // palette collapses to just its search field, which is exactly what shipped
522 // the first time this was run. Same reason `AboutPanel` pins its card.
523 let column = VStack::new()
524 .spacing(4.0)
525 .child(Padding::symmetric(8.0, 8.0).child(field))
526 .add_child(ctx.add_boxed(body))
527 .on_key(move |ev, ctx| match ev {
528 WidgetEvent::KeyDown {
529 key: Key::ArrowDown,
530 ..
531 } => {
532 key_state.step_selection(1);
533 EventResponse::Handled
534 }
535 WidgetEvent::KeyDown {
536 key: Key::ArrowUp, ..
537 } => {
538 key_state.step_selection(-1);
539 EventResponse::Handled
540 }
541 WidgetEvent::KeyDown {
542 key: Key::Escape, ..
543 } => {
544 key_state.dismiss(ctx);
545 EventResponse::Handled
546 }
547 _ => EventResponse::Ignored,
548 });
549
550 let root = ctx.add_boxed(Box::new(
551 FixedSize::new()
552 .width(PALETTE_WIDTH as f32)
553 .height(PALETTE_HEIGHT as f32)
554 .child(column),
555 ));
556 self.root_child_id = Some(root);
557 vec![root]
558 }
559
560 fn accessibility(&self, node: &mut AccessNodeBuilder) {
561 node.set_role(Role::Dialog);
562 // An unnamed dialog is announced as "dialog" and nothing else, which
563 // tells a screen-reader user that something opened but not what.
564 node.set_name(
565 tr_widget!(command_palette_title())
566 .resolve_now()
567 .to_string(),
568 );
569 node.set_modal();
570 // How many commands the query currently matches — the one fact a
571 // sighted user reads off the list at a glance and a screen-reader user
572 // otherwise has to arrow through the whole list to learn.
573 let count = self.state.rows.borrow().len();
574 node.set_description(
575 tr_widget!(command_palette_result_count(count = count as i64)).resolve_now(),
576 );
577 node.set_live(teksilo_core::accesskit::Live::Polite);
578 }
579
580 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
581 self.root_child_id
582 .and_then(|id| ctx.child_size(id, proposal))
583 .map(LayoutResponse::from)
584 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
585 }
586
587 fn place_children(
588 &self,
589 bounds: Rect,
590 _proposal: SizeProposal,
591 children: &mut [WidgetPlacement],
592 _ctx: &LayoutContext,
593 ) {
594 for child in children.iter_mut() {
595 child.origin = Point::new(bounds.x, bounds.y);
596 child.size = Size::new(bounds.width, bounds.height);
597 }
598 }
599
600 fn children(&self) -> Vec<WidgetId> {
601 self.root_child_id.into_iter().collect()
602 }
603}
604
605/// One rendered row: name over category on the left, chord on the right, on a
606/// selection-tinted ground.
607fn command_row(cmd: &PaletteCommand, selected: bool) -> impl Widget + 'static {
608 let name_color = if cmd.enabled {
609 TextRole::Primary
610 } else {
611 TextRole::Disabled
612 };
613 let mut left = VStack::new().spacing(1.0).child(
614 TextWidget::new(lit!(cmd.name.clone()))
615 .style(TextStyleRole::Body)
616 .color(name_color)
617 .single_line(),
618 );
619 // The category is the row's disambiguator — two features' "Close" read identically
620 // without it — so it shows always, not only while searching.
621 if let Some(cat) = cmd.category {
622 left = left.child(
623 TextWidget::new(lit!(cat.to_string()))
624 .style(TextStyleRole::Small)
625 .color(TextRole::Secondary)
626 .single_line(),
627 );
628 }
629
630 // An unbound command is the normal case here, not a defect, so it gets empty space
631 // rather than the em-dash a settings table uses to mean "nothing bound yet".
632 let chord = cmd.keystroke.map(format_keystroke).unwrap_or_default();
633
634 let row = HStack::new()
635 .spacing(10.0)
636 .child(left)
637 .child(Spacer::new())
638 .child(
639 TextWidget::new(lit!(chord))
640 .style(TextStyleRole::Small)
641 .color(TextRole::Secondary)
642 .single_line(),
643 );
644
645 // The highlight carries two channels, not one. A background tint alone is a
646 // colour-only distinction (WCAG 1.4.1) and disappears entirely under a
647 // high-contrast or forced-colours setting; the leading bar is a shape, so it
648 // survives both. Same reading as the selection edge `StandardListItem`
649 // draws — a palette row is a list row wearing different padding.
650 let bg = RectWidget::new().background(if selected {
651 SurfaceRole::Selected
652 } else {
653 SurfaceRole::Transparent
654 });
655 let marker =
656 FixedSize::new()
657 .width(SELECTION_MARKER_WIDTH)
658 .child(RectWidget::new().background(if selected {
659 ColorProp::from(BorderRole::Focused)
660 } else {
661 ColorProp::from(SurfaceRole::Transparent)
662 }));
663
664 ZStack::new().child(bg).child(
665 HStack::new()
666 .child(marker)
667 .child(Expand::new().child(Padding::symmetric(6.0, 10.0).child(row))),
668 )
669}
670
671// ── Matching ────────────────────────────────────────────────────────────────
672
673/// Score `haystack` against an already-lowercased `needle`, or `None` when the needle
674/// is not a subsequence of it.
675///
676/// Higher is better. The weights encode three preferences, strongest first: a run of
677/// typed letters landing consecutively beats the same letters scattered; a letter
678/// landing at the start of a word beats one landing mid-word; and an early match beats
679/// a late one. That is enough to put the row a person meant at the top for the queries
680/// people actually type, without a general-purpose ranking library.
681///
682/// A space in the needle matches a space in the haystack like any other character, so
683/// `file new` behaves as a two-word query against the "category name" haystack.
684fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
685 if needle.is_empty() {
686 return Some(0);
687 }
688 const CONSECUTIVE_BONUS: i32 = 15;
689 const WORD_START_BONUS: i32 = 20;
690 const GAP_PENALTY: i32 = 1;
691 const MAX_GAP_PENALTY: i32 = 20;
692
693 let hay: Vec<char> = haystack.to_lowercase().chars().collect();
694 // Word starts are read off the *original* casing, so a TitleCase or camelCase
695 // boundary counts even with no separator before it.
696 let raw: Vec<char> = haystack.chars().collect();
697 let is_word_start = |i: usize| -> bool {
698 if i == 0 {
699 return true;
700 }
701 // `hay` is the lowercased haystack and `raw` the original. Lowercasing can
702 // change the character count for some scripts, so only consult `raw` when the
703 // two line up; otherwise fall back to the separator test alone.
704 let Some(&prev) = raw.get(i.wrapping_sub(1)) else {
705 return true;
706 };
707 let Some(&cur) = raw.get(i) else {
708 return false;
709 };
710 !prev.is_alphanumeric() || (prev.is_lowercase() && cur.is_uppercase())
711 };
712
713 let mut score = 0;
714 let mut hay_pos = 0usize;
715 let mut last_match: Option<usize> = None;
716 // Length of the run of consecutive matches ending at the previous character. The
717 // bonus compounds with it, which is what makes a whole word typed out beat the
718 // same letters collected from the start of several words: `exp` must find
719 // "Export", not "Edit XML Properties", even though the latter matches three word
720 // starts and the former only one.
721 let mut streak = 0;
722
723 for want in needle.chars() {
724 let found = hay[hay_pos..].iter().position(|c| *c == want)? + hay_pos;
725 match last_match {
726 Some(prev) if found == prev + 1 => {
727 streak += 1;
728 score += CONSECUTIVE_BONUS * streak;
729 }
730 Some(prev) => {
731 streak = 0;
732 score -= ((found - prev - 1) as i32 * GAP_PENALTY).min(MAX_GAP_PENALTY);
733 }
734 // Reward matching near the front, so `new` prefers "New Window" over a
735 // command that merely contains the letters later on.
736 None => {
737 streak = 0;
738 score -= (found as i32 * GAP_PENALTY).min(MAX_GAP_PENALTY);
739 }
740 }
741 if is_word_start(found) {
742 score += WORD_START_BONUS;
743 }
744 last_match = Some(found);
745 hay_pos = found + 1;
746 }
747 Some(score)
748}
749
750#[cfg(test)]
751mod tests;