Skip to main content

iced_cube/navigation/
command.rs

1//! A searchable list of actions: a search field over grouped results.
2//!
3//! [`State`] holds the groups of [`Item`]s, the query and the highlighted
4//! row. Typing ranks the items with [`score`], a case-insensitive fuzzy
5//! match that prefers prefixes and word starts, and checks each item's
6//! keywords too. [`State::update`] returns an [`Output`] when an item is
7//! activated or the query changes.
8//!
9//! While the search field has focus, the list handles its own keys through
10//! a [`Keymap`] of [`Action`]s, before anything around it sees them; see
11//! [`default_keymap`]. Inside a dialog or popover, Escape first clears the
12//! query, and only then returns [`Output::Closed`] for the app to close
13//! whatever holds the list. Rows use the shared
14//! [menu look](crate::overlay::menu).
15//!
16//! Results can also arrive from background work. [`subscription`] owns a
17//! bounded channel and emits [`Event::Ready`] with its [`Sender`] first.
18//! When the query changes, `update` returns [`Output::Search`]; hand the
19//! query and a sender clone to a producer, which sends [`Results`] tagged
20//! with that query. Results for an older query are dropped.
21//!
22//! ```no_run
23//! use iced::{Element, Subscription};
24//! use iced_cube::lucide;
25//! use iced_cube::navigation::command::{self, Output, command, group, item};
26//!
27//! #[derive(Debug, Clone)]
28//! enum Message {
29//!     Command(command::Event<&'static str>),
30//! }
31//!
32//! struct App {
33//!     command: command::State<&'static str>,
34//! }
35//!
36//! impl App {
37//!     fn new() -> Self {
38//!         let command = command::State::new([group(
39//!             "Suggestions",
40//!             [
41//!                 item("calendar", "Calendar").icon(lucide!(Calendar)),
42//!                 item("settings", "Settings").shortcut("Ctrl+S"),
43//!             ],
44//!         )]);
45//!         Self { command }
46//!     }
47//!
48//!     fn update(&mut self, message: Message) {
49//!         let Message::Command(event) = message;
50//!         if let Some(Output::Activated(id)) = self.command.update(event) {
51//!             println!("Run {id}");
52//!         }
53//!     }
54//!
55//!     fn view(&self) -> Element<'_, Message> {
56//!         command(&self.command).on_event(Message::Command).into()
57//!     }
58//! }
59//! ```
60
61use std::fmt;
62use std::rc::Rc;
63
64use iced::futures::channel::mpsc;
65use iced::futures::{Stream, StreamExt, stream};
66use iced::keyboard::key::Named;
67use iced::mouse::ScrollDelta;
68use iced::widget::text::LineHeight;
69use iced::widget::text_input::{self, Status};
70use iced::widget::{self, Column, column, container, mouse_area, stack, text};
71use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Theme};
72
73use crate::icon::{Glyph, opacity, themed};
74use crate::keys::{self, Chord, Keymap};
75use crate::overlay::anchored::{anchored, surface_style};
76use crate::overlay::menu::{self, Parts, ROW_PADDING, RowStatus};
77use crate::theme::{Tokens, fade, space, text_size};
78
79/// How many result batches a producer can send before it has to wait.
80pub const CHANNEL_CAPACITY: usize = 64;
81/// The most result batches delivered in one [`Event::Received`].
82pub const BATCH_SIZE: usize = 32;
83/// How many rows show at once unless the state sets its own number.
84pub const VISIBLE_ROWS: usize = 8;
85/// Shown when nothing matches the query, unless the builder sets its own.
86pub const DEFAULT_EMPTY: &str = "No results found.";
87/// Shown while [`Command::loading`] is set.
88pub const SEARCHING: &str = "Searching...";
89
90const SEARCH_HEIGHT: f32 = 44.0;
91const ICON_SIZE: f32 = 16.0;
92const LINE_HEIGHT: f32 = 1.3;
93
94const EXACT: u32 = 5000;
95const PREFIX: u32 = 4000;
96const WORD: u32 = 3000;
97const CONTAINS: u32 = 2000;
98const FUZZY: u32 = 1000;
99const WORD_START: u32 = 10;
100const CONSECUTIVE: u32 = 5;
101/// Keyword matches rank just below the same kind of label match.
102const KEYWORD_PENALTY: u32 = 500;
103
104/// How well `query` matches `text`, ignoring case, or `None` when it does
105/// not match. Higher is better, and an empty query matches everything
106/// with 0.
107///
108/// An exact match ranks first, then a prefix, then the query at the start
109/// of a later word, then anywhere inside a word. Otherwise the letters must
110/// appear in order, and runs of letters and word starts score more. Within
111/// the first four kinds, shorter texts rank higher.
112///
113/// ```
114/// use iced_cube::navigation::command::score;
115///
116/// assert!(score("set", "Settings") > score("set", "Reset view"));
117/// assert!(score("nf", "New file").is_some());
118/// assert!(score("xyz", "Settings").is_none());
119/// ```
120pub fn score(query: &str, text: &str) -> Option<u32> {
121    let query: Vec<char> = query.trim().chars().flat_map(char::to_lowercase).collect();
122    if query.is_empty() {
123        return Some(0);
124    }
125    let folded = fold(text);
126    let chars: Vec<char> = folded.iter().map(|(c, _)| *c).collect();
127    let extra = chars.len().checked_sub(query.len())?;
128    let closeness = 999 - extra.min(999) as u32;
129
130    if extra == 0 && chars == query {
131        return Some(EXACT);
132    }
133    if chars.starts_with(&query) {
134        return Some(PREFIX + closeness);
135    }
136    let starts: Vec<usize> = chars
137        .windows(query.len())
138        .enumerate()
139        .filter(|(_, window)| *window == query.as_slice())
140        .map(|(start, _)| start)
141        .collect();
142    if starts.iter().any(|&start| folded[start].1) {
143        return Some(WORD + closeness);
144    }
145    if !starts.is_empty() {
146        return Some(CONTAINS + closeness);
147    }
148    fuzzy(&query, &folded).map(|bonus| FUZZY + bonus.min(999))
149}
150
151/// Lower-cased characters, each marked when it starts a word.
152fn fold(text: &str) -> Vec<(char, bool)> {
153    let mut folded = Vec::with_capacity(text.len());
154    let mut previous: Option<char> = None;
155    for c in text.chars() {
156        let starts_word = match previous {
157            None => true,
158            Some(p) => {
159                !p.is_alphanumeric() && c.is_alphanumeric() || p.is_lowercase() && c.is_uppercase()
160            }
161        };
162        for (n, lower) in c.to_lowercase().enumerate() {
163            folded.push((lower, starts_word && n == 0));
164        }
165        previous = Some(c);
166    }
167    folded
168}
169
170/// The best in-order match of every query letter, or `None` if there is
171/// none. Each matched letter scores 1, or more at a word start, plus a
172/// bonus when it follows the previous match directly.
173fn fuzzy(query: &[char], text: &[(char, bool)]) -> Option<u32> {
174    let mut previous: Vec<Option<u32>> = Vec::new();
175    for (i, &wanted) in query.iter().enumerate() {
176        let mut current = vec![None; text.len()];
177        let mut best_before: Option<u32> = None;
178        for (j, &(c, starts_word)) in text.iter().enumerate() {
179            if i > 0 && j >= 2 {
180                best_before = best_before.max(previous[j - 2]);
181            }
182            if c != wanted {
183                continue;
184            }
185            let letter = if starts_word { WORD_START } else { 1 };
186            let before = if i == 0 {
187                Some(0)
188            } else {
189                let adjacent = j
190                    .checked_sub(1)
191                    .and_then(|k| previous[k])
192                    .map(|score| score + CONSECUTIVE);
193                adjacent.max(best_before)
194            };
195            current[j] = before.map(|score| score + letter);
196        }
197        previous = current;
198    }
199    previous.into_iter().flatten().max()
200}
201
202/// One action in a [`Group`].
203#[derive(Debug, Clone, PartialEq)]
204pub struct Item<Id> {
205    pub id: Id,
206    pub label: String,
207    pub icon: Option<Glyph>,
208    /// A hint such as `Ctrl+S`, shown at the end of the row.
209    pub shortcut: Option<String>,
210    /// Other words that find this item.
211    pub keywords: Vec<String>,
212    pub disabled: bool,
213    pub destructive: bool,
214}
215
216/// Creates an enabled item.
217pub fn item<Id>(id: Id, label: impl Into<String>) -> Item<Id> {
218    Item {
219        id,
220        label: label.into(),
221        icon: None,
222        shortcut: None,
223        keywords: Vec::new(),
224        disabled: false,
225        destructive: false,
226    }
227}
228
229impl<Id> Item<Id> {
230    pub fn icon(mut self, glyph: Glyph) -> Self {
231        self.icon = Some(glyph);
232        self
233    }
234
235    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
236        self.shortcut = Some(shortcut.into());
237        self
238    }
239
240    pub fn keywords<S: Into<String>>(mut self, keywords: impl IntoIterator<Item = S>) -> Self {
241        self.keywords = keywords.into_iter().map(Into::into).collect();
242        self
243    }
244
245    /// Shows the item muted, skipped by the keyboard and not clickable.
246    pub fn disabled(mut self, disabled: bool) -> Self {
247        self.disabled = disabled;
248        self
249    }
250
251    /// Colours the label as a destructive action.
252    pub fn destructive(mut self, destructive: bool) -> Self {
253        self.destructive = destructive;
254        self
255    }
256
257    /// How well `query` matches the label or, ranked a little lower, a
258    /// keyword. See [`score`].
259    pub fn score(&self, query: &str) -> Option<u32> {
260        let keyword = self
261            .keywords
262            .iter()
263            .filter_map(|keyword| score(query, keyword))
264            .max()
265            .map(|score| score.saturating_sub(KEYWORD_PENALTY));
266        score(query, &self.label).max(keyword)
267    }
268}
269
270/// Items under a label. An empty label shows no heading.
271#[derive(Debug, Clone, PartialEq)]
272pub struct Group<Id> {
273    pub label: String,
274    pub items: Vec<Item<Id>>,
275}
276
277pub fn group<Id>(label: impl Into<String>, items: impl IntoIterator<Item = Item<Id>>) -> Group<Id> {
278    Group {
279        label: label.into(),
280        items: items.into_iter().collect(),
281    }
282}
283
284/// Items a producer found for a query, shown under `group` after the
285/// built-in groups. They are shown in the order they arrive, unranked.
286#[derive(Debug, Clone, PartialEq)]
287pub struct Results<Id> {
288    pub query: String,
289    pub group: String,
290    pub items: Vec<Item<Id>>,
291}
292
293pub fn results<Id>(
294    query: impl Into<String>,
295    group: impl Into<String>,
296    items: impl IntoIterator<Item = Item<Id>>,
297) -> Results<Id> {
298    Results {
299        query: query.into(),
300        group: group.into(),
301        items: items.into_iter().collect(),
302    }
303}
304
305/// The sending half of the results channel. Clone it for each producer.
306pub type Sender<Id> = mpsc::Sender<Results<Id>>;
307
308/// Everything that changes a command list.
309#[derive(Debug, Clone)]
310pub enum Event<Id> {
311    /// The channel is open. Keep the sender and hand clones to producers.
312    Ready(Sender<Id>),
313    /// Results from the channel, oldest first.
314    Received(Vec<Results<Id>>),
315    /// The query was edited.
316    Input(String),
317    /// Highlights the next enabled row, wrapping round.
318    Next,
319    /// Highlights the previous enabled row, wrapping round.
320    Previous,
321    First,
322    Last,
323    /// Activates the highlighted row.
324    ActivateHighlighted,
325    /// Clears the query, or closes the list when it is already empty.
326    Close,
327    /// Highlights a row, by its position in [`State::results`].
328    Highlight(usize),
329    /// Activates a row, by its position in [`State::results`].
330    Activate(usize),
331    /// Scrolls the list by a number of rows, down when positive.
332    Scroll(i32),
333    /// The search field gained or lost focus. The widget sends this itself.
334    Focus(bool),
335}
336
337/// What the app may need to act on after an [`Event`].
338#[derive(Debug, Clone)]
339pub enum Output<Id> {
340    Ready(Sender<Id>),
341    /// The query changed. Start any background search for it here.
342    Search(String),
343    /// An item was chosen.
344    Activated(Id),
345    /// Close was pressed with an empty query: close whatever holds the
346    /// list, such as a dialog.
347    Closed,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
351struct Entry {
352    remote: bool,
353    group: usize,
354    item: usize,
355}
356
357/// The groups, the query, the ranked results and the highlighted row.
358#[derive(Debug, Clone, PartialEq)]
359pub struct State<Id> {
360    groups: Vec<Group<Id>>,
361    remote: Vec<Group<Id>>,
362    query: String,
363    entries: Vec<Entry>,
364    highlighted: Option<usize>,
365    offset: usize,
366    rows: usize,
367    focused: bool,
368    navigated: bool,
369}
370
371impl<Id> State<Id> {
372    /// Lists every item and highlights the first enabled one. The highlight
373    /// shows once the field has focus or the user moves it.
374    pub fn new(groups: impl IntoIterator<Item = Group<Id>>) -> Self {
375        let mut state = Self {
376            groups: groups.into_iter().collect(),
377            remote: Vec::new(),
378            query: String::new(),
379            entries: Vec::new(),
380            highlighted: None,
381            offset: 0,
382            rows: VISIBLE_ROWS,
383            focused: false,
384            navigated: false,
385        };
386        state.rank(false);
387        state
388    }
389
390    /// Sets how many rows show at once. At least one does.
391    pub fn with_visible_rows(mut self, rows: usize) -> Self {
392        self.rows = rows.max(1);
393        self.reveal();
394        self
395    }
396
397    pub fn query(&self) -> &str {
398        &self.query
399    }
400
401    pub fn groups(&self) -> &[Group<Id>] {
402        &self.groups
403    }
404
405    /// Every result in display order, with its group label.
406    pub fn results(&self) -> impl Iterator<Item = (&str, &Item<Id>)> {
407        self.entries.iter().filter_map(|&entry| {
408            let group = self.group(entry)?;
409            Some((group.label.as_str(), group.items.get(entry.item)?))
410        })
411    }
412
413    pub fn len(&self) -> usize {
414        self.entries.len()
415    }
416
417    pub fn is_empty(&self) -> bool {
418        self.entries.is_empty()
419    }
420
421    /// The highlighted item, if any result is enabled.
422    pub fn highlighted(&self) -> Option<&Item<Id>> {
423        self.highlighted.and_then(|position| self.item_at(position))
424    }
425
426    /// Position in [`State::results`] of the highlighted row.
427    pub fn highlighted_position(&self) -> Option<usize> {
428        self.highlighted
429    }
430
431    /// Whether the highlighted row is drawn highlighted: while the search
432    /// field has focus, or once the user has moved the highlight. An idle
433    /// inline list shows no highlight, though Enter still has a target.
434    pub fn shows_highlight(&self) -> bool {
435        self.focused || self.navigated
436    }
437
438    /// Whether the search field has focus.
439    pub fn is_focused(&self) -> bool {
440        self.focused
441    }
442
443    fn group(&self, entry: Entry) -> Option<&Group<Id>> {
444        let groups = if entry.remote {
445            &self.remote
446        } else {
447            &self.groups
448        };
449        groups.get(entry.group)
450    }
451
452    fn item_at(&self, position: usize) -> Option<&Item<Id>> {
453        let entry = *self.entries.get(position)?;
454        self.group(entry)?.items.get(entry.item)
455    }
456
457    fn is_enabled(&self, position: usize) -> bool {
458        self.item_at(position).is_some_and(|item| !item.disabled)
459    }
460
461    fn has_enabled(&self) -> bool {
462        (0..self.entries.len()).any(|position| self.is_enabled(position))
463    }
464
465    /// Applies an event and returns what the app may need to act on.
466    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>>
467    where
468        Id: Clone,
469    {
470        if matches!(
471            event,
472            Event::Next
473                | Event::Previous
474                | Event::First
475                | Event::Last
476                | Event::Highlight(_)
477                | Event::Activate(_)
478        ) {
479            self.navigated = true;
480        }
481        match event {
482            Event::Ready(sender) => return Some(Output::Ready(sender)),
483            Event::Received(batch) => self.receive(batch),
484            Event::Input(query) => return self.search(query),
485            Event::Next => self.step(true),
486            Event::Previous => self.step(false),
487            Event::First => {
488                let first = (0..self.entries.len()).find(|&p| self.is_enabled(p));
489                self.highlight(first);
490            }
491            Event::Last => {
492                let last = (0..self.entries.len()).rev().find(|&p| self.is_enabled(p));
493                self.highlight(last);
494            }
495            Event::Focus(focused) => {
496                self.focused = focused;
497                if !focused {
498                    self.navigated = false;
499                }
500            }
501            Event::ActivateHighlighted => {
502                let id = self.highlighted()?.id.clone();
503                return Some(Output::Activated(id));
504            }
505            Event::Close if self.query.is_empty() => return Some(Output::Closed),
506            Event::Close => return self.search(String::new()),
507            Event::Highlight(position) => {
508                if self.is_enabled(position) {
509                    self.highlighted = Some(position);
510                }
511            }
512            Event::Activate(position) => {
513                if !self.is_enabled(position) {
514                    return None;
515                }
516                self.highlighted = Some(position);
517                let id = self.item_at(position)?.id.clone();
518                return Some(Output::Activated(id));
519            }
520            Event::Scroll(rows) => {
521                let last = self.entries.len().saturating_sub(self.rows);
522                let offset = (self.offset as i64).saturating_add(rows.into());
523                self.offset = offset.clamp(0, last as i64) as usize;
524            }
525        }
526        None
527    }
528
529    fn search(&mut self, query: String) -> Option<Output<Id>> {
530        if query == self.query {
531            return None;
532        }
533        self.query = query;
534        self.remote.clear();
535        self.rank(false);
536        Some(Output::Search(self.query.clone()))
537    }
538
539    fn receive(&mut self, batch: Vec<Results<Id>>) {
540        let mut changed = false;
541        for results in batch {
542            if results.query != self.query || results.items.is_empty() {
543                continue;
544            }
545            changed = true;
546            match self.remote.iter_mut().find(|g| g.label == results.group) {
547                Some(group) => group.items.extend(results.items),
548                None => self.remote.push(group(results.group, results.items)),
549            }
550        }
551        if changed {
552            self.rank(true);
553        }
554    }
555
556    /// Rebuilds the result list. Built-in groups are filtered and sorted
557    /// by score, results from producers are kept as they arrived.
558    fn rank(&mut self, keep_highlight: bool) {
559        let kept = keep_highlight
560            .then(|| self.highlighted.and_then(|p| self.entries.get(p).copied()))
561            .flatten();
562
563        let mut entries = Vec::new();
564        for (index, group) in self.groups.iter().enumerate() {
565            let mut scored: Vec<(u32, usize)> = group
566                .items
567                .iter()
568                .enumerate()
569                .filter_map(|(index, item)| item.score(&self.query).map(|score| (score, index)))
570                .collect();
571            scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
572            entries.extend(scored.into_iter().map(|(_, item)| Entry {
573                remote: false,
574                group: index,
575                item,
576            }));
577        }
578        for (index, group) in self.remote.iter().enumerate() {
579            entries.extend((0..group.items.len()).map(|item| Entry {
580                remote: true,
581                group: index,
582                item,
583            }));
584        }
585        self.entries = entries;
586
587        let kept = kept.and_then(|entry| self.entries.iter().position(|e| *e == entry));
588        if kept.is_none() {
589            self.offset = 0;
590        }
591        let first = kept.or_else(|| (0..self.entries.len()).find(|&p| self.is_enabled(p)));
592        self.highlight(first);
593    }
594
595    /// The nearest enabled row in one direction, wrapping round.
596    fn step(&mut self, forward: bool) {
597        let len = self.entries.len();
598        if len == 0 {
599            return;
600        }
601        let start = self
602            .highlighted
603            .unwrap_or(if forward { len - 1 } else { 0 });
604        let offset = if forward { 1 } else { len - 1 };
605        let target = (1..=len)
606            .map(|n| (start + offset * n) % len)
607            .find(|&position| self.is_enabled(position));
608        if target.is_some() {
609            self.highlight(target);
610        }
611    }
612
613    fn highlight(&mut self, position: Option<usize>) {
614        self.highlighted = position;
615        self.reveal();
616    }
617
618    /// Moves the window so the highlighted row is in view.
619    fn reveal(&mut self) {
620        if let Some(position) = self.highlighted {
621            if position < self.offset {
622                self.offset = position;
623            } else if position >= self.offset + self.rows {
624                self.offset = position + 1 - self.rows;
625            }
626        }
627        self.offset = self
628            .offset
629            .min(self.entries.len().saturating_sub(self.rows));
630    }
631
632    /// Positions in [`State::results`] of the rows in view.
633    pub fn visible(&self) -> std::ops::Range<usize> {
634        let end = (self.offset + self.rows).min(self.entries.len());
635        self.offset.min(end)..end
636    }
637}
638
639/// What a command keyboard shortcut does.
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
641pub enum Action {
642    Next,
643    Previous,
644    First,
645    Last,
646    Activate,
647    Close,
648}
649
650impl Action {
651    /// The [`Event`] this action sends to `state`, or `None` when it would
652    /// do nothing, so the key can reach the search field instead.
653    pub fn event<Id>(self, state: &State<Id>) -> Option<Event<Id>> {
654        let movable = state.has_enabled();
655        match self {
656            Action::Next => movable.then_some(Event::Next),
657            Action::Previous => movable.then_some(Event::Previous),
658            Action::First => movable.then_some(Event::First),
659            Action::Last => movable.then_some(Event::Last),
660            Action::Activate => state
661                .highlighted
662                .is_some()
663                .then_some(Event::ActivateHighlighted),
664            Action::Close => Some(Event::Close),
665        }
666    }
667}
668
669impl keys::Action for Action {
670    const ALL: &'static [Self] = &[
671        Action::Next,
672        Action::Previous,
673        Action::First,
674        Action::Last,
675        Action::Activate,
676        Action::Close,
677    ];
678
679    fn defaults() -> Keymap<Self> {
680        default_keymap()
681    }
682
683    fn name(self) -> &'static str {
684        match self {
685            Action::Next => "Next",
686            Action::Previous => "Previous",
687            Action::First => "First",
688            Action::Last => "Last",
689            Action::Activate => "Activate",
690            Action::Close => "Close",
691        }
692    }
693
694    fn description(self) -> &'static str {
695        match self {
696            Action::Next => "Highlights the next enabled result, wrapping at the end.",
697            Action::Previous => "Highlights the previous enabled result, wrapping at the start.",
698            Action::First => "Highlights the first enabled result.",
699            Action::Last => "Highlights the last enabled result.",
700            Action::Activate => "Activates the highlighted result.",
701            Action::Close => "Clears the query, or closes the list when the query is empty.",
702        }
703    }
704}
705
706/// The default command shortcuts:
707///
708/// | Keys | Action |
709/// | --- | --- |
710/// | `ArrowDown` | [`Action::Next`] |
711/// | `ArrowUp` | [`Action::Previous`] |
712/// | `Home` | [`Action::First`] |
713/// | `End` | [`Action::Last`] |
714/// | `Enter` | [`Action::Activate`] |
715/// | `Escape` | [`Action::Close`] |
716///
717/// The list handles these itself while its search field has focus, so
718/// Home and End move the highlight rather than the text cursor. Pass a
719/// changed keymap with [`Command::keymap`]. To use them without focus,
720/// resolve presses from [`keys::subscription`] and send
721/// [`Action::event`] to the state.
722pub fn default_keymap() -> Keymap<Action> {
723    Keymap::new()
724        .bind(Chord::named(Named::ArrowDown), Action::Next)
725        .bind(Chord::named(Named::ArrowUp), Action::Previous)
726        .bind(Chord::named(Named::Home), Action::First)
727        .bind(Chord::named(Named::End), Action::Last)
728        .bind(Chord::named(Named::Enter), Action::Activate)
729        .bind(Chord::named(Named::Escape), Action::Close)
730}
731
732/// Owns the results channel. Emits [`Event::Ready`] first, then batches.
733pub fn subscription<Id: Send + 'static>() -> Subscription<Event<Id>> {
734    Subscription::run(stream::<Id>)
735}
736
737/// The stream behind [`subscription`], for driving it without a runtime.
738pub fn stream<Id: Send + 'static>() -> impl Stream<Item = Event<Id>> {
739    let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
740    stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
741}
742
743/// Groups whatever is already waiting in the channel into one batch.
744pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
745    receiver.ready_chunks(BATCH_SIZE)
746}
747
748/// A command list builder. Convert it into an [`Element`] to render.
749///
750/// A command list without [`on_event`](Command::on_event) is rendered
751/// disabled.
752pub struct Command<'a, Id, Message> {
753    state: &'a State<Id>,
754    placeholder: String,
755    empty: String,
756    loading: bool,
757    width: Length,
758    height: Length,
759    max_height: f32,
760    id: Option<widget::Id>,
761    keymap: Keymap<Action>,
762    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
763}
764
765/// Renders the search field and results of `state`.
766pub fn command<'a, Id, Message>(state: &'a State<Id>) -> Command<'a, Id, Message> {
767    Command {
768        state,
769        placeholder: "Type a command or search...".to_owned(),
770        empty: DEFAULT_EMPTY.to_owned(),
771        loading: false,
772        width: Length::Fill,
773        height: Length::Shrink,
774        max_height: f32::INFINITY,
775        id: None,
776        keymap: default_keymap(),
777        on_event: None,
778    }
779}
780
781impl<Id: fmt::Debug, Message> fmt::Debug for Command<'_, Id, Message> {
782    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
783        f.debug_struct("Command")
784            .field("state", self.state)
785            .field("placeholder", &self.placeholder)
786            .field("empty", &self.empty)
787            .field("loading", &self.loading)
788            .field("enabled", &self.on_event.is_some())
789            .finish_non_exhaustive()
790    }
791}
792
793impl<'a, Id, Message> Command<'a, Id, Message> {
794    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
795        self.placeholder = placeholder.into();
796        self
797    }
798
799    /// Text shown when nothing matches the query.
800    pub fn empty(mut self, empty: impl Into<String>) -> Self {
801        self.empty = empty.into();
802        self
803    }
804
805    /// Shows that a background search is still running.
806    pub fn loading(mut self, loading: bool) -> Self {
807        self.loading = loading;
808        self
809    }
810
811    /// Overrides the width. Fills the available width by default.
812    pub fn width(mut self, width: impl Into<Length>) -> Self {
813        self.width = width.into();
814        self
815    }
816
817    /// Overrides the height. Fits the results by default; a fixed height
818    /// stops the list resizing as the results change.
819    pub fn height(mut self, height: impl Into<Length>) -> Self {
820        self.height = height.into();
821        self
822    }
823
824    /// Caps the height in pixels while the list still shrinks to fit a
825    /// few results. Rows past the cap are clipped, so keep
826    /// [`State::with_visible_rows`] within it.
827    pub fn max_height(mut self, max_height: f32) -> Self {
828        self.max_height = max_height;
829        self
830    }
831
832    /// Sets the widget id of the search field, to focus it or find it in
833    /// tests.
834    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
835        self.id = Some(id.into());
836        self
837    }
838
839    /// Replaces the [`default_keymap`].
840    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
841        self.keymap = keymap;
842        self
843    }
844
845    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
846        self.on_event = Some(Box::new(on_event));
847        self
848    }
849
850    pub fn is_enabled(&self) -> bool {
851        self.on_event.is_some()
852    }
853}
854
855impl<'a, Id, Message> From<Command<'a, Id, Message>> for Element<'a, Message>
856where
857    Id: 'a,
858    Message: Clone + 'a,
859{
860    fn from(command: Command<'a, Id, Message>) -> Self {
861        let Command {
862            state,
863            placeholder,
864            empty,
865            loading,
866            width,
867            height,
868            max_height,
869            id,
870            keymap,
871            on_event,
872        } = command;
873
874        let on_event: Option<Rc<dyn Fn(Event<Id>) -> Message + 'a>> = on_event.map(Rc::from);
875        let list = list(state, &empty, loading, on_event.as_deref());
876        let list = container(list).height(if height == Length::Shrink {
877            Length::Shrink
878        } else {
879            Length::Fill
880        });
881        let on_input = on_event.clone().map(|on_event| {
882            Box::new(move |query| on_event(Event::Input(query)))
883                as Box<dyn Fn(String) -> Message + 'a>
884        });
885
886        let surface = container(column![
887            search_field(&placeholder, &state.query, id, on_input),
888            container(widget::space())
889                .width(Length::Fill)
890                .height(1)
891                .style(|theme| divider_style(&Tokens::of(theme))),
892            list,
893        ])
894        .width(width)
895        .height(height)
896        .max_height(max_height)
897        .clip(true)
898        .style(|theme| surface_style(&Tokens::of(theme)));
899
900        let Some(on_event) = on_event else {
901            return surface.into();
902        };
903        let on_focus = on_event.clone();
904        anchored(surface)
905            .dismiss_keys([])
906            .on_focus(move |focused| on_focus(Event::Focus(focused)))
907            .on_key(move |key| {
908                keymap
909                    .resolve_event(key)
910                    .and_then(|action| action.event(state))
911                    .map(&*on_event)
912            })
913            .into()
914    }
915}
916
917fn search_field<'a, Message: Clone + 'a>(
918    placeholder: &str,
919    value: &str,
920    id: Option<widget::Id>,
921    on_input: Option<impl Fn(String) -> Message + 'a>,
922) -> Element<'a, Message> {
923    let enabled = on_input.is_some();
924    let vertical = (SEARCH_HEIGHT - text_size::SM * LINE_HEIGHT) / 2.0;
925    let mut input = widget::text_input(placeholder, value)
926        .size(text_size::SM)
927        .line_height(LineHeight::Relative(LINE_HEIGHT))
928        .padding(Padding {
929            top: vertical,
930            bottom: vertical,
931            left: space::MD + ICON_SIZE + space::SM,
932            right: space::MD,
933        })
934        .width(Length::Fill)
935        .style(|theme, status| search_style(&Tokens::of(theme), status));
936    if let Some(id) = id {
937        input = input.id(id);
938    }
939    if let Some(on_input) = on_input {
940        input = input.on_input(on_input);
941    }
942
943    let icon = themed(
944        crate::lucide!(Search),
945        menu::ICON_SIZE,
946        opacity(enabled),
947        |theme| Tokens::of(theme).muted_foreground,
948    );
949
950    stack![
951        input,
952        container(icon)
953            .height(Length::Fill)
954            .align_y(Alignment::Center)
955            .padding(Padding::ZERO.left(space::MD)),
956    ]
957    .into()
958}
959
960fn list<'a, Id, Message: Clone + 'a>(
961    state: &'a State<Id>,
962    empty: &str,
963    loading: bool,
964    on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
965) -> Element<'a, Message> {
966    let mut children: Vec<Element<'a, Message>> = Vec::new();
967    let mut previous: Option<(bool, usize)> = None;
968
969    for position in state.visible() {
970        let Some(&entry) = state.entries.get(position) else {
971            continue;
972        };
973        let Some(group) = state.group(entry) else {
974            continue;
975        };
976        let Some(item) = group.items.get(entry.item) else {
977            continue;
978        };
979
980        let key = (entry.remote, entry.group);
981        if previous != Some(key) {
982            if previous.is_some() {
983                children.push(menu::separator());
984            }
985            if !group.label.is_empty() {
986                children.push(menu::group_label(&group.label, false));
987            }
988            previous = Some(key);
989        }
990
991        let enabled = on_event.is_some() && !item.disabled;
992        let status = if !enabled {
993            RowStatus::Disabled
994        } else if state.shows_highlight() && state.highlighted == Some(position) {
995            RowStatus::Highlighted
996        } else {
997            RowStatus::Idle
998        };
999        let messages = on_event.filter(|_| enabled).map(|on_event| {
1000            (
1001                on_event(Event::Highlight(position)),
1002                on_event(Event::Activate(position)),
1003            )
1004        });
1005        let parts = Parts {
1006            icon: item.icon,
1007            hint: item.shortcut.as_deref(),
1008            ..Parts::label(&item.label)
1009        };
1010        children.push(menu::item(parts, status, item.destructive, messages));
1011    }
1012
1013    if children.is_empty() {
1014        return menu::empty(if loading { SEARCHING } else { empty });
1015    }
1016    if loading {
1017        children.push(menu::inset(
1018            container(muted(SEARCHING.to_owned(), text_size::SM)).padding(ROW_PADDING),
1019        ));
1020    }
1021
1022    let rows = Column::from_vec(children)
1023        .padding([space::XS, 0.0])
1024        .width(Length::Fill);
1025    let Some(on_event) = on_event else {
1026        return rows.into();
1027    };
1028    let down = on_event(Event::Scroll(1));
1029    let up = on_event(Event::Scroll(-1));
1030    mouse_area(rows)
1031        .on_scroll(move |delta| match delta {
1032            ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } if y < 0.0 => down.clone(),
1033            _ => up.clone(),
1034        })
1035        .into()
1036}
1037
1038fn muted<'a, Message: 'a>(content: String, size: f32) -> Element<'a, Message> {
1039    text(content)
1040        .size(size)
1041        .line_height(LineHeight::Absolute(menu::LINE_HEIGHT.into()))
1042        .style(|theme: &Theme| text::Style {
1043            color: Some(Tokens::of(theme).muted_foreground),
1044        })
1045        .into()
1046}
1047
1048/// The search field: borderless and transparent, inside the surface.
1049pub fn search_style(tokens: &Tokens, status: Status) -> text_input::Style {
1050    let style = text_input::Style {
1051        background: Background::Color(Color::TRANSPARENT),
1052        border: Border::default(),
1053        icon: tokens.muted_foreground,
1054        placeholder: tokens.muted_foreground,
1055        value: tokens.foreground,
1056        selection: fade(tokens.primary, 0.25),
1057    };
1058    if status != Status::Disabled {
1059        return style;
1060    }
1061    text_input::Style {
1062        placeholder: fade(style.placeholder, 0.5),
1063        value: fade(style.value, 0.5),
1064        icon: fade(style.icon, 0.5),
1065        selection: Color::TRANSPARENT,
1066        ..style
1067    }
1068}
1069
1070/// The line under the search field and between groups.
1071pub fn divider_style(tokens: &Tokens) -> container::Style {
1072    container::Style {
1073        background: Some(Background::Color(tokens.border)),
1074        ..container::Style::default()
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use super::*;
1081    use crate::theme::{dark, light};
1082    use futures::SinkExt;
1083    use futures::executor::block_on;
1084
1085    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1086        let chord: Chord = chord.parse().unwrap();
1087        keymap.resolve(chord.key(), chord.modifiers())
1088    }
1089
1090    fn sample() -> State<&'static str> {
1091        State::new([
1092            group(
1093                "Suggestions",
1094                [
1095                    item("calendar", "Calendar"),
1096                    item("emoji", "Search emoji").keywords(["smiley"]),
1097                    item("calculator", "Calculator").disabled(true),
1098                ],
1099            ),
1100            group(
1101                "Settings",
1102                [
1103                    item("profile", "Profile").shortcut("Ctrl+P"),
1104                    item("billing", "Billing"),
1105                    item("settings", "Settings"),
1106                ],
1107            ),
1108        ])
1109    }
1110
1111    fn ids(state: &State<&'static str>) -> Vec<&'static str> {
1112        state.results().map(|(_, item)| item.id).collect()
1113    }
1114
1115    fn highlighted(state: &State<&'static str>) -> Option<&'static str> {
1116        state.highlighted().map(|item| item.id)
1117    }
1118
1119    #[test]
1120    fn empty_query_matches_everything_equally() {
1121        assert_eq!(score("", "Anything"), Some(0));
1122        assert_eq!(score("   ", "Anything"), Some(0));
1123    }
1124
1125    #[test]
1126    fn matching_ignores_case() {
1127        assert_eq!(score("SET", "settings"), score("set", "Settings"));
1128        assert!(score("cAl", "CALENDAR").is_some());
1129    }
1130
1131    #[test]
1132    fn match_kinds_rank_in_order() {
1133        let exact = score("set", "Set");
1134        let prefix = score("set", "Settings");
1135        let word = score("set", "Reset settings");
1136        let contains = score("set", "Asset");
1137        let fuzzy = score("set", "Sheet editor");
1138        assert!(exact > prefix, "{exact:?} {prefix:?}");
1139        assert!(prefix > word, "{prefix:?} {word:?}");
1140        assert!(word > contains, "{word:?} {contains:?}");
1141        assert!(contains > fuzzy, "{contains:?} {fuzzy:?}");
1142        assert!(fuzzy.is_some());
1143    }
1144
1145    #[test]
1146    fn letters_must_appear_in_order() {
1147        assert!(score("tes", "Settings").is_none());
1148        assert!(score("xyz", "Settings").is_none());
1149        assert!(score("settings!", "Settings").is_none());
1150    }
1151
1152    #[test]
1153    fn shorter_texts_win_within_a_kind() {
1154        assert!(score("set", "Settings") > score("set", "Settings and privacy"));
1155    }
1156
1157    #[test]
1158    fn fuzzy_prefers_word_starts_and_runs() {
1159        assert!(score("ts", "Toggle sidebar") > score("ts", "Tabs list"));
1160        assert!(score("nf", "newFile") > score("nf", "notify"));
1161        assert!(score("sba", "Show bar") > score("sba", "Toggle sidebar"));
1162        assert!(score("ogl", "toggle") > score("ogl", "tobgaly"));
1163    }
1164
1165    #[test]
1166    fn keywords_find_items_but_rank_below_labels() {
1167        let emoji = item("emoji", "Search emoji").keywords(["smiley"]);
1168        assert!(emoji.score("smil").is_some());
1169        let smiley = item("smiley", "Smiley");
1170        assert!(smiley.score("smil") > emoji.score("smil"));
1171        assert!(item("x", "Plain").score("smil").is_none());
1172    }
1173
1174    #[test]
1175    fn new_state_lists_every_item_and_highlights_the_first() {
1176        let state = sample();
1177        assert_eq!(state.len(), 6);
1178        assert_eq!(highlighted(&state), Some("calendar"));
1179        assert_eq!(state.query(), "");
1180        assert!(State::<u8>::new([]).is_empty());
1181        assert!(State::<u8>::new([]).highlighted().is_none());
1182    }
1183
1184    #[test]
1185    fn the_highlight_shows_only_while_focused_or_after_navigating() {
1186        let mut state = sample();
1187        assert!(!state.shows_highlight());
1188        assert!(!state.is_focused());
1189
1190        let _ = state.update(Event::Focus(true));
1191        assert!(state.shows_highlight() && state.is_focused());
1192        let _ = state.update(Event::Focus(false));
1193        assert!(!state.shows_highlight());
1194
1195        for event in [
1196            Event::Next,
1197            Event::Previous,
1198            Event::First,
1199            Event::Last,
1200            Event::Highlight(0),
1201        ] {
1202            let mut state = sample();
1203            let _ = state.update(event);
1204            assert!(state.shows_highlight());
1205            let _ = state.update(Event::Focus(true));
1206            let _ = state.update(Event::Focus(false));
1207            assert!(!state.shows_highlight(), "leaving the field hides it again");
1208        }
1209    }
1210
1211    #[test]
1212    fn typing_or_scrolling_alone_does_not_show_the_highlight() {
1213        let mut state = sample();
1214        let _ = state.update(Event::Input("set".into()));
1215        let _ = state.update(Event::Scroll(1));
1216        assert!(!state.shows_highlight());
1217        assert!(state.highlighted().is_some(), "Enter still has a target");
1218    }
1219
1220    #[test]
1221    fn typing_filters_ranks_and_asks_for_a_search() {
1222        let mut state = sample();
1223        let output = state.update(Event::Input("cal".into()));
1224        assert!(matches!(output, Some(Output::Search(query)) if query == "cal"));
1225        assert_eq!(ids(&state), ["calendar", "calculator"]);
1226        assert_eq!(highlighted(&state), Some("calendar"));
1227
1228        // Calculator is the closer match now, but it is disabled.
1229        let _ = state.update(Event::Input("calc".into()));
1230        assert_eq!(ids(&state), ["calculator"]);
1231        assert!(state.highlighted().is_none());
1232
1233        let _ = state.update(Event::Input("set".into()));
1234        assert_eq!(ids(&state), ["settings"]);
1235        let groups: Vec<_> = state.results().map(|(group, _)| group).collect();
1236        assert_eq!(groups, ["Settings"]);
1237
1238        assert!(state.update(Event::Input("set".into())).is_none());
1239    }
1240
1241    #[test]
1242    fn nothing_matching_leaves_nothing_highlighted() {
1243        let mut state = sample();
1244        let _ = state.update(Event::Input("zzz".into()));
1245        assert!(state.is_empty());
1246        assert!(state.highlighted().is_none());
1247        assert!(state.update(Event::ActivateHighlighted).is_none());
1248        let _ = state.update(Event::Next);
1249        assert!(state.highlighted().is_none());
1250    }
1251
1252    #[test]
1253    fn arrows_wrap_and_skip_disabled_items() {
1254        let mut state = sample();
1255        let _ = state.update(Event::Next);
1256        assert_eq!(highlighted(&state), Some("emoji"));
1257        let _ = state.update(Event::Next);
1258        assert_eq!(highlighted(&state), Some("profile"));
1259        let _ = state.update(Event::Previous);
1260        let _ = state.update(Event::Previous);
1261        let _ = state.update(Event::Previous);
1262        assert_eq!(highlighted(&state), Some("settings"));
1263        let _ = state.update(Event::Next);
1264        assert_eq!(highlighted(&state), Some("calendar"));
1265    }
1266
1267    #[test]
1268    fn home_and_end_jump_to_enabled_ends() {
1269        let mut state = State::new([group(
1270            "",
1271            [
1272                item(1, "One").disabled(true),
1273                item(2, "Two"),
1274                item(3, "Three"),
1275                item(4, "Four").disabled(true),
1276            ],
1277        )]);
1278        let _ = state.update(Event::Last);
1279        assert_eq!(state.highlighted().map(|i| i.id), Some(3));
1280        let _ = state.update(Event::First);
1281        assert_eq!(state.highlighted().map(|i| i.id), Some(2));
1282    }
1283
1284    #[test]
1285    fn run_and_activate_return_the_id() {
1286        let mut state = sample();
1287        let _ = state.update(Event::Next);
1288        assert!(matches!(
1289            state.update(Event::ActivateHighlighted),
1290            Some(Output::Activated("emoji"))
1291        ));
1292
1293        assert!(matches!(
1294            state.update(Event::Activate(4)),
1295            Some(Output::Activated("billing"))
1296        ));
1297        assert_eq!(highlighted(&state), Some("billing"));
1298        assert!(state.update(Event::Activate(2)).is_none());
1299        assert!(state.update(Event::Activate(99)).is_none());
1300    }
1301
1302    #[test]
1303    fn hovering_a_disabled_row_keeps_the_highlight() {
1304        let mut state = sample();
1305        let _ = state.update(Event::Highlight(2));
1306        assert_eq!(highlighted(&state), Some("calendar"));
1307        let _ = state.update(Event::Highlight(3));
1308        assert_eq!(highlighted(&state), Some("profile"));
1309    }
1310
1311    #[test]
1312    fn clear_empties_the_query_then_dismisses() {
1313        let mut state = sample();
1314        let _ = state.update(Event::Input("bill".into()));
1315        let output = state.update(Event::Close);
1316        assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
1317        assert_eq!(state.len(), 6);
1318        assert!(matches!(state.update(Event::Close), Some(Output::Closed)));
1319    }
1320
1321    #[test]
1322    fn results_for_the_current_query_join_a_remote_group() {
1323        let mut state = sample();
1324        let _ = state.update(Event::Input("rep".into()));
1325        assert!(state.is_empty());
1326
1327        let _ = state.update(Event::Received(vec![
1328            results("rep", "Files", [item("a", "report.pdf")]),
1329            results("rep", "Files", [item("b", "reply.txt")]),
1330        ]));
1331        assert_eq!(ids(&state), ["a", "b"]);
1332        assert!(state.results().all(|(group, _)| group == "Files"));
1333        assert_eq!(highlighted(&state), Some("a"));
1334    }
1335
1336    #[test]
1337    fn stale_results_are_dropped() {
1338        let mut state = sample();
1339        let _ = state.update(Event::Input("re".into()));
1340        let _ = state.update(Event::Input("rep".into()));
1341        let _ = state.update(Event::Received(vec![results(
1342            "re",
1343            "Files",
1344            [item("old", "readme.md")],
1345        )]));
1346        assert!(state.is_empty());
1347    }
1348
1349    #[test]
1350    fn a_new_query_clears_remote_results() {
1351        let mut state = sample();
1352        let files = |state: &State<&'static str>| {
1353            state
1354                .results()
1355                .filter(|(group, _)| *group == "Files")
1356                .count()
1357        };
1358        let _ = state.update(Event::Input("re".into()));
1359        let _ = state.update(Event::Received(vec![results(
1360            "re",
1361            "Files",
1362            [item("old", "readme.md")],
1363        )]));
1364        assert_eq!(files(&state), 1);
1365        let _ = state.update(Event::Input("rea".into()));
1366        assert_eq!(files(&state), 0);
1367    }
1368
1369    #[test]
1370    fn arriving_results_keep_the_highlight() {
1371        let mut state = sample();
1372        let _ = state.update(Event::Input("s".into()));
1373        let _ = state.update(Event::Next);
1374        let before = highlighted(&state);
1375        let _ = state.update(Event::Received(vec![results(
1376            "s",
1377            "Files",
1378            [item("f", "styles.css")],
1379        )]));
1380        assert_eq!(highlighted(&state), before);
1381        assert_eq!(state.results().last().map(|(_, item)| item.id), Some("f"));
1382    }
1383
1384    #[test]
1385    fn remote_results_are_not_filtered_again() {
1386        let mut state = sample();
1387        let _ = state.update(Event::Input("q".into()));
1388        let _ = state.update(Event::Received(vec![results(
1389            "q",
1390            "Files",
1391            [item("x", "unrelated.txt")],
1392        )]));
1393        assert_eq!(ids(&state), ["x"]);
1394    }
1395
1396    #[test]
1397    fn the_window_follows_the_highlight_and_scrolls() {
1398        let mut state = sample().with_visible_rows(3);
1399        assert_eq!(state.visible(), 0..3);
1400        let _ = state.update(Event::Last);
1401        assert_eq!(state.visible(), 3..6);
1402        let _ = state.update(Event::Next);
1403        assert_eq!(state.visible(), 0..3);
1404        let _ = state.update(Event::Scroll(10));
1405        assert_eq!(state.visible(), 3..6);
1406        let _ = state.update(Event::Scroll(-1));
1407        assert_eq!(state.visible(), 2..5);
1408        assert_eq!(State::<u8>::new([]).with_visible_rows(0).rows, 1);
1409        assert_eq!(State::<u8>::new([]).visible(), 0..0);
1410    }
1411
1412    #[test]
1413    fn ready_hands_the_sender_back() {
1414        let (sender, _receiver) = mpsc::channel(1);
1415        let output = sample().update(Event::Ready(sender));
1416        assert!(matches!(output, Some(Output::Ready(_))));
1417    }
1418
1419    #[test]
1420    fn default_keymap_and_overrides() {
1421        let keymap = default_keymap();
1422        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1423        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1424        assert_eq!(press(&keymap, "Home"), Some(Action::First));
1425        assert_eq!(press(&keymap, "End"), Some(Action::Last));
1426        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1427        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1428
1429        let custom = keymap
1430            .bind("Ctrl+N".parse().unwrap(), Action::Next)
1431            .unbind(&Chord::named(Named::Home));
1432        assert_eq!(press(&custom, "ctrl+n"), Some(Action::Next));
1433        assert_eq!(press(&custom, "Home"), None);
1434        assert!(default_keymap().clear().is_empty());
1435    }
1436
1437    #[test]
1438    fn actions_map_to_events_only_when_they_apply() {
1439        let mut state = sample();
1440        for action in [Action::Next, Action::Previous, Action::First, Action::Last] {
1441            assert!(action.event(&state).is_some(), "{action:?}");
1442        }
1443        assert!(matches!(
1444            Action::Activate.event(&state),
1445            Some(Event::ActivateHighlighted)
1446        ));
1447        assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1448
1449        let _ = state.update(Event::Input("zzz".into()));
1450        for action in [
1451            Action::Next,
1452            Action::Previous,
1453            Action::First,
1454            Action::Last,
1455            Action::Activate,
1456        ] {
1457            assert!(action.event(&state).is_none(), "{action:?}");
1458        }
1459        assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1460    }
1461
1462    #[test]
1463    fn stream_emits_ready_first_then_batches() {
1464        let mut events = Box::pin(stream::<&'static str>());
1465        let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
1466            panic!("first event must be Ready");
1467        };
1468        block_on(async {
1469            for name in ["a", "b", "c"] {
1470                sender
1471                    .send(results("q", "Files", [item(name, name)]))
1472                    .await
1473                    .ok();
1474            }
1475        });
1476        let Some(Event::Received(batch)) = block_on(events.next()) else {
1477            panic!("expected a batch");
1478        };
1479        let names: Vec<_> = batch.iter().map(|r| r.items[0].id).collect();
1480        assert_eq!(names, ["a", "b", "c"]);
1481    }
1482
1483    #[test]
1484    fn batches_split_bursts_at_batch_size() {
1485        let items = stream::iter(0..BATCH_SIZE + 3);
1486        let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
1487        assert_eq!(sizes, [BATCH_SIZE, 3]);
1488    }
1489
1490    #[test]
1491    fn default_builder_is_disabled() {
1492        let state = sample();
1493        let c: Command<'_, &str, ()> = command(&state);
1494        assert!(!c.is_enabled());
1495        assert_eq!(c.empty, DEFAULT_EMPTY);
1496        assert!(!c.loading);
1497        assert_eq!((c.width, c.height), (Length::Fill, Length::Shrink));
1498        assert_eq!(c.max_height, f32::INFINITY);
1499        let c = c
1500            .loading(true)
1501            .height(320)
1502            .max_height(400.0)
1503            .on_event(|_| ());
1504        assert!(c.is_enabled() && c.loading);
1505        assert_eq!(c.height, Length::Fixed(320.0));
1506        assert_eq!(c.max_height, 400.0);
1507    }
1508
1509    const STATES: [Status; 5] = [
1510        Status::Active,
1511        Status::Hovered,
1512        Status::Focused { is_hovered: false },
1513        Status::Focused { is_hovered: true },
1514        Status::Disabled,
1515    ];
1516
1517    #[test]
1518    fn search_field_is_borderless_and_fades_when_disabled() {
1519        for theme in [light(), dark()] {
1520            let tokens = Tokens::of(&theme);
1521            for status in STATES {
1522                let style = search_style(&tokens, status);
1523                assert_eq!(style.border.width, 0.0);
1524                assert_eq!(style.background, Background::Color(Color::TRANSPARENT));
1525                let expected = if status == Status::Disabled {
1526                    fade(tokens.foreground, 0.5)
1527                } else {
1528                    tokens.foreground
1529                };
1530                assert_eq!(style.value, expected, "{status:?}");
1531            }
1532        }
1533    }
1534
1535    #[test]
1536    fn dividers_use_the_border_token() {
1537        for theme in [light(), dark()] {
1538            let tokens = Tokens::of(&theme);
1539            assert_eq!(
1540                divider_style(&tokens).background,
1541                Some(Background::Color(tokens.border))
1542            );
1543        }
1544    }
1545}