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;