Skip to main content

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;