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::ListBoxOption` 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    /// Move the selection to an absolute row (Home / End).
230    fn select_edge(&self, last: bool) {
231        let len = self.rows.borrow().len();
232        if len == 0 {
233            return;
234        }
235        let target = if last { len - 1 } else { 0 };
236        self.set_selected(target);
237        self.reveal(target);
238    }
239
240    /// Scroll the minimum distance that brings row `index` into view.
241    fn reveal(&self, index: usize) {
242        let top = self.top_index.get();
243        let new_top = if index < top {
244            index
245        } else if index >= top + VISIBLE_ROWS {
246            index + 1 - VISIBLE_ROWS
247        } else {
248            top
249        };
250        if new_top != top {
251            self.top_index.set(new_top);
252        }
253    }
254
255    /// Send the highlighted command's intent, then dismiss.
256    ///
257    /// The intent is synthesized from the command's declared name, which is what the
258    /// dispatcher sends for a chord with no custom activation closure — so a command
259    /// reached from the palette and the same command reached from its keystroke
260    /// arrive at the identical action.
261    fn activate_selected(&self, ctx: &mut EventContext) {
262        let picked = {
263            let rows = self.rows.borrow();
264            rows.get(self.selected.get()).cloned()
265        };
266        let Some(cmd) = picked else { return };
267        if !cmd.enabled {
268            // Reachable only with `show_disabled`, where a greyed row is displayed
269            // precisely to say "not now" — running it anyway would make the grey a lie.
270            return;
271        }
272        ctx.send_intent(Intent::new(cmd.intent));
273        let dismiss = self.on_dismiss.borrow().clone();
274        if let Some(dismiss) = dismiss {
275            dismiss(ctx);
276        }
277    }
278
279    fn dismiss(&self, ctx: &mut EventContext) {
280        let dismiss = self.on_dismiss.borrow().clone();
281        if let Some(dismiss) = dismiss {
282            dismiss(ctx);
283        }
284    }
285}
286
287/// Type-to-run access to every registered command. See the [module docs](self).
288pub struct CommandPalette {
289    state: PaletteState,
290    placeholder: Option<LocalizedString>,
291    empty_text: Option<LocalizedString>,
292    include: Option<IncludeFn>,
293    show_disabled: bool,
294    root_child_id: Option<WidgetId>,
295}
296
297impl Default for CommandPalette {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303impl CommandPalette {
304    /// A palette over every command in the tree's registry.
305    pub fn new() -> Self {
306        Self {
307            state: PaletteState::new(),
308            placeholder: None,
309            empty_text: None,
310            include: None,
311            show_disabled: false,
312            root_child_id: None,
313        }
314    }
315
316    /// Replace the search field's placeholder text.
317    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
318        self.placeholder = Some(text.into());
319        self
320    }
321
322    /// Replace the text shown when nothing matches the query.
323    pub fn empty_text(mut self, text: impl Into<LocalizedString>) -> Self {
324        self.empty_text = Some(text.into());
325        self
326    }
327
328    /// Keep only the commands this predicate accepts.
329    ///
330    /// The usual reasons are to hide the command that opens the palette itself, and
331    /// to drop registry entries that are key bindings rather than commands a person
332    /// would look for by name.
333    pub fn include(mut self, f: impl Fn(&PaletteCommand) -> bool + 'static) -> Self {
334        self.include = Some(Rc::new(f));
335        self
336    }
337
338    /// Run this after a command is activated, and when Escape is pressed.
339    ///
340    /// [`present`](Self::present) installs its own, so this is for callers embedding
341    /// the palette in a surface they manage themselves.
342    pub fn on_dismiss(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
343        *self.state.on_dismiss.borrow_mut() = Some(Rc::new(f));
344        self
345    }
346
347    /// Also list commands whose `enabled_when` predicate currently says no, greyed
348    /// out and inert. Off by default: a palette answers "what can I do now", and a
349    /// row that cannot run is a row that has to be explained.
350    pub fn show_disabled(mut self, show: bool) -> Self {
351        self.show_disabled = show;
352        self
353    }
354
355    /// The query signal, so a caller can seed or observe what was typed.
356    pub fn query_signal(&self) -> Signal<String> {
357        self.state.query.clone()
358    }
359
360    /// Show the palette centered in the window, dismissed by Escape or a click
361    /// outside. See the [module docs](self) on why this is window-level.
362    pub fn present(self, ctx: &mut EventContext) {
363        let palette = if self.state.on_dismiss.borrow().is_none() {
364            self.on_dismiss(|ctx| ctx.dismiss_modal())
365        } else {
366            self
367        };
368        let mut inner = Some(palette);
369        ctx.present_modal(
370            ModalRequest::deferred(move |tree| {
371                let palette = inner
372                    .take()
373                    .expect("CommandPalette present closure called twice");
374                tree.add(ModalContainer::new(palette))
375            })
376            .presentation(ModalPresentation::InTree)
377            .close_behavior(ModalCloseBehavior::EscapeOrClickOutside)
378            .size(PALETTE_WIDTH, PALETTE_HEIGHT),
379        );
380    }
381
382    /// Read the registry, apply `include`, match against the query, and rank.
383    fn visible_rows(&self, ctx: &BuildContext) -> Vec<PaletteCommand> {
384        let needle = self.state.query.get().trim().to_lowercase();
385        let mut scored: Vec<(i32, PaletteCommand)> = ctx
386            .shortcut_registry()
387            .iter_effective()
388            .map(|eff| PaletteCommand {
389                id: eff.shortcut.id,
390                name: eff.shortcut.name.get(),
391                category: eff.shortcut.category,
392                description: eff.shortcut.description.as_ref().map(|d| d.get()),
393                keystroke: eff.primary,
394                enabled: eff.enabled,
395                intent: eff.shortcut.intent_name(),
396            })
397            .filter(|cmd| self.show_disabled || cmd.enabled)
398            .filter(|cmd| self.include.as_ref().is_none_or(|f| f(cmd)))
399            .filter_map(|cmd| Some((fuzzy_score(&needle, &cmd.haystack())?, cmd)))
400            .collect();
401        // Highest score first. `iter_effective` already ordered by (category, id) and
402        // `sort_by` is stable, so equal scores — which is every row when the query is
403        // empty — keep exactly that order.
404        scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
405        scored.into_iter().map(|(_, cmd)| cmd).collect()
406    }
407}
408
409impl std::fmt::Debug for CommandPalette {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        f.debug_struct("CommandPalette")
412            .field("query", &self.state.query.get())
413            .field("rows", &self.state.rows.borrow().len())
414            .finish_non_exhaustive()
415    }
416}
417
418impl Widget for CommandPalette {
419    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
420        // Any registry change — a command registered, rebound, enabled — changes what
421        // the palette should be showing.
422        ctx.shortcut_version().bind_to(
423            ctx.self_id(),
424            ctx.binding_registry(),
425            BindingLevel::Rebuild,
426        );
427        self.state
428            .query
429            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
430        self.state
431            .selected
432            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
433        self.state
434            .top_index
435            .bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
436
437        let rows = self.visible_rows(ctx);
438        // A changed query means a different list: send the highlight back to the best
439        // match rather than leaving it on whatever now happens to sit at that index.
440        let query_now = self.state.query.get();
441        if *self.state.last_query.borrow() != query_now {
442            *self.state.last_query.borrow_mut() = query_now;
443            self.state.set_selected(0);
444            self.state.top_index.set(0);
445        }
446        // Clamp before rendering, not after: a query that shortened the list must not
447        // leave the highlight past the end for even one frame, or Enter would run
448        // whichever row happens to sit at a stale index.
449        if self.state.selected.get() >= rows.len() {
450            self.state.set_selected(rows.len().saturating_sub(1));
451        }
452        // Keep the AT-visible selection on the highlight even when neither
453        // branch above fired (first build, or a rebuild driven by the shortcut
454        // registry rather than by a keystroke).
455        if !rows.is_empty() && !self.state.selection.is_selected(self.state.selected.get()) {
456            self.state.selection.select(self.state.selected.get());
457        }
458        *self.state.rows.borrow_mut() = rows.clone();
459        let selected_index = self.state.selected.get();
460
461        let placeholder = self
462            .placeholder
463            .clone()
464            .unwrap_or_else(|| tr_widget!(command_palette_placeholder()));
465
466        let submit_state = self.state.clone();
467        let field = SearchField::new(self.state.query.clone())
468            .placeholder(placeholder)
469            .label(tr_widget!(command_palette_title()))
470            .on_submit_fn(move |ctx| submit_state.activate_selected(ctx))
471            // The ARIA combobox pattern. Focus never leaves this field — that
472            // is what makes a palette feel like one — so the arrow-key
473            // highlight has to be announced through the field's own AT node.
474            // `SearchField` forwards both down to the focusable
475            // `TextInputField`, the only node whose `active_descendant`
476            // assistive technology follows.
477            .drives_listbox(self.state.listbox_id.clone(), self.state.active_row.clone());
478
479        // Stale entries would otherwise survive an empty result set and point
480        // `active_descendant` at a destroyed node.
481        self.state.listbox_id.set(None);
482        self.state.active_row.set(None);
483
484        let body: Box<dyn Widget> = if rows.is_empty() {
485            let empty = self
486                .empty_text
487                .clone()
488                .unwrap_or_else(|| tr_widget!(command_palette_empty()));
489            Box::new(
490                Padding::symmetric(14.0, 12.0).child(
491                    TextWidget::new(empty)
492                        .style(TextStyleRole::Body)
493                        .color(TextRole::Secondary),
494                ),
495            )
496        } else {
497            let list = ListView::new(
498                ListModel::from_vec(rows),
499                move |index, cmd: &PaletteCommand, _row_selected| {
500                    Box::new(command_row(cmd, index == selected_index))
501                },
502            )
503            .item_height(ROW_HEIGHT)
504            // Makes each row's `Role::ListBoxOption` report `selected` truthfully.
505            .selection(self.state.selection.clone());
506            // Take the realized-row map before the view moves into the tree.
507            let row_ids = list.realized_row_ids();
508            // The window the reader is looking at is state this widget owns, so it
509            // survives the rebuild that every keystroke causes.
510            list.scroll_to_index(self.state.top_index.get());
511
512            // `ctx.add` builds the subtree synchronously, so by the time this
513            // returns the body pane has already published its realized rows and
514            // the highlighted row's id is resolvable — no deferred effect, no
515            // frame of silence after an arrow key.
516            let list_id = ctx.add(list);
517            self.state.listbox_id.set(Some(list_id));
518            let active = row_ids
519                .borrow()
520                .iter()
521                .find(|(index, _)| *index == selected_index)
522                .map(|(_, id)| *id);
523            self.state.active_row.set(active);
524
525            Box::new(Expand::new().child_id(list_id))
526        };
527
528        let key_state = self.state.clone();
529        // The column is pinned to the presented size rather than left to size itself.
530        // `ModalContainer` sizes to its content, and the result list lives under an
531        // `Expand` — with no bounded height to fill, the list measures zero and the
532        // palette collapses to just its search field, which is exactly what shipped
533        // the first time this was run. Same reason `AboutPanel` pins its card.
534        let column = VStack::new()
535            .spacing(4.0)
536            .child(Padding::symmetric(8.0, 8.0).child(field))
537            .add_child(ctx.add_boxed(body))
538            .on_key(move |ev, ctx| match ev {
539                WidgetEvent::KeyDown {
540                    key: Key::ArrowDown,
541                    ..
542                } => {
543                    key_state.step_selection(1);
544                    EventResponse::Handled
545                }
546                WidgetEvent::KeyDown {
547                    key: Key::ArrowUp, ..
548                } => {
549                    key_state.step_selection(-1);
550                    EventResponse::Handled
551                }
552                // A palette is a list, and a long result set is exactly
553                // where jumping to an end matters — these were the only list
554                // keys it did not answer.
555                WidgetEvent::KeyDown { key: Key::Home, .. } => {
556                    key_state.select_edge(false);
557                    EventResponse::Handled
558                }
559                WidgetEvent::KeyDown { key: Key::End, .. } => {
560                    key_state.select_edge(true);
561                    EventResponse::Handled
562                }
563                WidgetEvent::KeyDown {
564                    key: Key::PageUp, ..
565                } => {
566                    key_state.step_selection(-(VISIBLE_ROWS as isize));
567                    EventResponse::Handled
568                }
569                WidgetEvent::KeyDown {
570                    key: Key::PageDown, ..
571                } => {
572                    key_state.step_selection(VISIBLE_ROWS as isize);
573                    EventResponse::Handled
574                }
575                WidgetEvent::KeyDown {
576                    key: Key::Escape, ..
577                } => {
578                    key_state.dismiss(ctx);
579                    EventResponse::Handled
580                }
581                _ => EventResponse::Ignored,
582            });
583
584        let root = ctx.add_boxed(Box::new(
585            FixedSize::new()
586                .width(PALETTE_WIDTH as f32)
587                .height(PALETTE_HEIGHT as f32)
588                .child(column),
589        ));
590        self.root_child_id = Some(root);
591        vec![root]
592    }
593
594    fn accessibility(&self, node: &mut AccessNodeBuilder) {
595        node.set_role(Role::Dialog);
596        // An unnamed dialog is announced as "dialog" and nothing else, which
597        // tells a screen-reader user that something opened but not what.
598        node.set_name(
599            tr_widget!(command_palette_title())
600                .resolve_now()
601                .to_string(),
602        );
603        node.set_modal();
604        // How many commands the query currently matches — the one fact a
605        // sighted user reads off the list at a glance and a screen-reader user
606        // otherwise has to arrow through the whole list to learn.
607        let count = self.state.rows.borrow().len();
608        node.set_description(
609            tr_widget!(command_palette_result_count(count = count as i64)).resolve_now(),
610        );
611        node.set_live(teksilo_core::accesskit::Live::Polite);
612    }
613
614    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
615        self.root_child_id
616            .and_then(|id| ctx.child_size(id, proposal))
617            .map(LayoutResponse::from)
618            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
619    }
620
621    fn place_children(
622        &self,
623        bounds: Rect,
624        _proposal: SizeProposal,
625        children: &mut [WidgetPlacement],
626        _ctx: &LayoutContext,
627    ) {
628        for child in children.iter_mut() {
629            child.origin = Point::new(bounds.x, bounds.y);
630            child.size = Size::new(bounds.width, bounds.height);
631        }
632    }
633
634    fn children(&self) -> Vec<WidgetId> {
635        self.root_child_id.into_iter().collect()
636    }
637}
638
639/// One rendered row: name over category on the left, chord on the right, on a
640/// selection-tinted ground.
641fn command_row(cmd: &PaletteCommand, selected: bool) -> impl Widget + 'static {
642    let name_color = if cmd.enabled {
643        TextRole::Primary
644    } else {
645        TextRole::Disabled
646    };
647    let mut left = VStack::new().spacing(1.0).child(
648        TextWidget::new(lit!(cmd.name.clone()))
649            .style(TextStyleRole::Body)
650            .color(name_color)
651            .single_line(),
652    );
653    // The category is the row's disambiguator — two features' "Close" read identically
654    // without it — so it shows always, not only while searching.
655    if let Some(cat) = cmd.category {
656        left = left.child(
657            TextWidget::new(lit!(cat.to_string()))
658                .style(TextStyleRole::Small)
659                .color(TextRole::Secondary)
660                .single_line(),
661        );
662    }
663
664    // An unbound command is the normal case here, not a defect, so it gets empty space
665    // rather than the em-dash a settings table uses to mean "nothing bound yet".
666    let chord = cmd.keystroke.map(format_keystroke).unwrap_or_default();
667
668    let row = HStack::new()
669        .spacing(10.0)
670        .child(left)
671        .child(Spacer::new())
672        .child(
673            TextWidget::new(lit!(chord))
674                .style(TextStyleRole::Small)
675                .color(TextRole::Secondary)
676                .single_line(),
677        );
678
679    // The highlight carries two channels, not one. A background tint alone is a
680    // colour-only distinction (WCAG 1.4.1) and disappears entirely under a
681    // high-contrast or forced-colours setting; the leading bar is a shape, so it
682    // survives both. Same reading as the selection edge `StandardListItem`
683    // draws — a palette row is a list row wearing different padding.
684    let bg = RectWidget::new().background(if selected {
685        SurfaceRole::Selected
686    } else {
687        SurfaceRole::Transparent
688    });
689    let marker =
690        FixedSize::new()
691            .width(SELECTION_MARKER_WIDTH)
692            .child(RectWidget::new().background(if selected {
693                ColorProp::from(BorderRole::Focused)
694            } else {
695                ColorProp::from(SurfaceRole::Transparent)
696            }));
697
698    ZStack::new().child(bg).child(
699        HStack::new()
700            .child(marker)
701            .child(Expand::new().child(Padding::symmetric(6.0, 10.0).child(row))),
702    )
703}
704
705// ── Matching ────────────────────────────────────────────────────────────────
706
707/// Score `haystack` against an already-lowercased `needle`, or `None` when the needle
708/// is not a subsequence of it.
709///
710/// Higher is better. The weights encode three preferences, strongest first: a run of
711/// typed letters landing consecutively beats the same letters scattered; a letter
712/// landing at the start of a word beats one landing mid-word; and an early match beats
713/// a late one. That is enough to put the row a person meant at the top for the queries
714/// people actually type, without a general-purpose ranking library.
715///
716/// A space in the needle matches a space in the haystack like any other character, so
717/// `file new` behaves as a two-word query against the "category name" haystack.
718fn fuzzy_score(needle: &str, haystack: &str) -> Option<i32> {
719    if needle.is_empty() {
720        return Some(0);
721    }
722    const CONSECUTIVE_BONUS: i32 = 15;
723    const WORD_START_BONUS: i32 = 20;
724    const GAP_PENALTY: i32 = 1;
725    const MAX_GAP_PENALTY: i32 = 20;
726
727    let hay: Vec<char> = haystack.to_lowercase().chars().collect();
728    // Word starts are read off the *original* casing, so a TitleCase or camelCase
729    // boundary counts even with no separator before it.
730    let raw: Vec<char> = haystack.chars().collect();
731    let is_word_start = |i: usize| -> bool {
732        if i == 0 {
733            return true;
734        }
735        // `hay` is the lowercased haystack and `raw` the original. Lowercasing can
736        // change the character count for some scripts, so only consult `raw` when the
737        // two line up; otherwise fall back to the separator test alone.
738        let Some(&prev) = raw.get(i.wrapping_sub(1)) else {
739            return true;
740        };
741        let Some(&cur) = raw.get(i) else {
742            return false;
743        };
744        !prev.is_alphanumeric() || (prev.is_lowercase() && cur.is_uppercase())
745    };
746
747    let mut score = 0;
748    let mut hay_pos = 0usize;
749    let mut last_match: Option<usize> = None;
750    // Length of the run of consecutive matches ending at the previous character. The
751    // bonus compounds with it, which is what makes a whole word typed out beat the
752    // same letters collected from the start of several words: `exp` must find
753    // "Export", not "Edit XML Properties", even though the latter matches three word
754    // starts and the former only one.
755    let mut streak = 0;
756
757    for want in needle.chars() {
758        let found = hay[hay_pos..].iter().position(|c| *c == want)? + hay_pos;
759        match last_match {
760            Some(prev) if found == prev + 1 => {
761                streak += 1;
762                score += CONSECUTIVE_BONUS * streak;
763            }
764            Some(prev) => {
765                streak = 0;
766                score -= ((found - prev - 1) as i32 * GAP_PENALTY).min(MAX_GAP_PENALTY);
767            }
768            // Reward matching near the front, so `new` prefers "New Window" over a
769            // command that merely contains the letters later on.
770            None => {
771                streak = 0;
772                score -= (found as i32 * GAP_PENALTY).min(MAX_GAP_PENALTY);
773            }
774        }
775        if is_word_start(found) {
776            score += WORD_START_BONUS;
777        }
778        last_match = Some(found);
779        hay_pos = found + 1;
780    }
781    Some(score)
782}
783
784#[cfg(test)]
785mod tests;