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        // The best enabled match in any group, as (score, position).
565        let mut best: Option<(u32, usize)> = None;
566        for (index, group) in self.groups.iter().enumerate() {
567            let mut scored: Vec<(u32, usize)> = group
568                .items
569                .iter()
570                .enumerate()
571                .filter_map(|(index, item)| item.score(&self.query).map(|score| (score, index)))
572                .collect();
573            scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
574            for (score, item) in scored {
575                let enabled = group.items.get(item).is_some_and(|item| !item.disabled);
576                if enabled && best.is_none_or(|(top, _)| score > top) {
577                    best = Some((score, entries.len()));
578                }
579                entries.push(Entry {
580                    remote: false,
581                    group: index,
582                    item,
583                });
584            }
585        }
586        // Groups keep their order, but typing highlights the best match in
587        // any of them, so Enter runs what was typed for.
588        let best = best
589            .filter(|_| !self.query.trim().is_empty())
590            .map(|(_, position)| position);
591        for (index, group) in self.remote.iter().enumerate() {
592            entries.extend((0..group.items.len()).map(|item| Entry {
593                remote: true,
594                group: index,
595                item,
596            }));
597        }
598        self.entries = entries;
599
600        let kept = kept.and_then(|entry| self.entries.iter().position(|e| *e == entry));
601        if kept.is_none() {
602            self.offset = 0;
603        }
604        let first = kept
605            .or(best)
606            .or_else(|| (0..self.entries.len()).find(|&p| self.is_enabled(p)));
607        self.highlight(first);
608    }
609
610    /// The nearest enabled row in one direction, wrapping round.
611    fn step(&mut self, forward: bool) {
612        let len = self.entries.len();
613        if len == 0 {
614            return;
615        }
616        let start = self
617            .highlighted
618            .unwrap_or(if forward { len - 1 } else { 0 });
619        let offset = if forward { 1 } else { len - 1 };
620        let target = (1..=len)
621            .map(|n| (start + offset * n) % len)
622            .find(|&position| self.is_enabled(position));
623        if target.is_some() {
624            self.highlight(target);
625        }
626    }
627
628    fn highlight(&mut self, position: Option<usize>) {
629        self.highlighted = position;
630        self.reveal();
631    }
632
633    /// Moves the window so the highlighted row is in view.
634    fn reveal(&mut self) {
635        if let Some(position) = self.highlighted {
636            if position < self.offset {
637                self.offset = position;
638            } else if position >= self.offset + self.rows {
639                self.offset = position + 1 - self.rows;
640            }
641        }
642        self.offset = self
643            .offset
644            .min(self.entries.len().saturating_sub(self.rows));
645    }
646
647    /// Positions in [`State::results`] of the rows in view.
648    pub fn visible(&self) -> std::ops::Range<usize> {
649        let end = (self.offset + self.rows).min(self.entries.len());
650        self.offset.min(end)..end
651    }
652}
653
654/// What a command keyboard shortcut does.
655#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
656pub enum Action {
657    Next,
658    Previous,
659    First,
660    Last,
661    Activate,
662    Close,
663}
664
665impl Action {
666    /// The [`Event`] this action sends to `state`, or `None` when it would
667    /// do nothing, so the key can reach the search field instead.
668    pub fn event<Id>(self, state: &State<Id>) -> Option<Event<Id>> {
669        let movable = state.has_enabled();
670        match self {
671            Action::Next => movable.then_some(Event::Next),
672            Action::Previous => movable.then_some(Event::Previous),
673            Action::First => movable.then_some(Event::First),
674            Action::Last => movable.then_some(Event::Last),
675            Action::Activate => state
676                .highlighted
677                .is_some()
678                .then_some(Event::ActivateHighlighted),
679            Action::Close => Some(Event::Close),
680        }
681    }
682}
683
684impl keys::Action for Action {
685    const ALL: &'static [Self] = &[
686        Action::Next,
687        Action::Previous,
688        Action::First,
689        Action::Last,
690        Action::Activate,
691        Action::Close,
692    ];
693
694    fn defaults() -> Keymap<Self> {
695        default_keymap()
696    }
697
698    fn name(self) -> &'static str {
699        match self {
700            Action::Next => "Next",
701            Action::Previous => "Previous",
702            Action::First => "First",
703            Action::Last => "Last",
704            Action::Activate => "Activate",
705            Action::Close => "Close",
706        }
707    }
708
709    fn description(self) -> &'static str {
710        match self {
711            Action::Next => "Highlights the next enabled result, wrapping at the end.",
712            Action::Previous => "Highlights the previous enabled result, wrapping at the start.",
713            Action::First => "Highlights the first enabled result.",
714            Action::Last => "Highlights the last enabled result.",
715            Action::Activate => "Activates the highlighted result.",
716            Action::Close => "Clears the query, or closes the list when the query is empty.",
717        }
718    }
719}
720
721/// The default command shortcuts:
722///
723/// | Keys | Action |
724/// | --- | --- |
725/// | `ArrowDown` | [`Action::Next`] |
726/// | `ArrowUp` | [`Action::Previous`] |
727/// | `Home` | [`Action::First`] |
728/// | `End` | [`Action::Last`] |
729/// | `Enter` | [`Action::Activate`] |
730/// | `Escape` | [`Action::Close`] |
731///
732/// The list handles these itself while its search field has focus, so
733/// Home and End move the highlight rather than the text cursor. Pass a
734/// changed keymap with [`Command::keymap`]. To use them without focus,
735/// resolve presses from [`keys::subscription`] and send
736/// [`Action::event`] to the state.
737pub fn default_keymap() -> Keymap<Action> {
738    Keymap::new()
739        .bind(Chord::named(Named::ArrowDown), Action::Next)
740        .bind(Chord::named(Named::ArrowUp), Action::Previous)
741        .bind(Chord::named(Named::Home), Action::First)
742        .bind(Chord::named(Named::End), Action::Last)
743        .bind(Chord::named(Named::Enter), Action::Activate)
744        .bind(Chord::named(Named::Escape), Action::Close)
745}
746
747/// Owns the results channel. Emits [`Event::Ready`] first, then batches.
748pub fn subscription<Id: Send + 'static>() -> Subscription<Event<Id>> {
749    Subscription::run(stream::<Id>)
750}
751
752/// The stream behind [`subscription`], for driving it without a runtime.
753pub fn stream<Id: Send + 'static>() -> impl Stream<Item = Event<Id>> {
754    let (sender, receiver) = mpsc::channel(CHANNEL_CAPACITY);
755    stream::once(async move { Event::Ready(sender) }).chain(batches(receiver).map(Event::Received))
756}
757
758/// Groups whatever is already waiting in the channel into one batch.
759pub fn batches<T>(receiver: impl Stream<Item = T>) -> impl Stream<Item = Vec<T>> {
760    receiver.ready_chunks(BATCH_SIZE)
761}
762
763/// A command list builder. Convert it into an [`Element`] to render.
764///
765/// A command list without [`on_event`](Command::on_event) is rendered
766/// disabled.
767pub struct Command<'a, Id, Message> {
768    state: &'a State<Id>,
769    placeholder: String,
770    empty: String,
771    loading: bool,
772    width: Length,
773    height: Length,
774    max_height: f32,
775    id: Option<widget::Id>,
776    keymap: Keymap<Action>,
777    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
778}
779
780/// Renders the search field and results of `state`.
781pub fn command<'a, Id, Message>(state: &'a State<Id>) -> Command<'a, Id, Message> {
782    Command {
783        state,
784        placeholder: "Type a command or search...".to_owned(),
785        empty: DEFAULT_EMPTY.to_owned(),
786        loading: false,
787        width: Length::Fill,
788        height: Length::Shrink,
789        max_height: f32::INFINITY,
790        id: None,
791        keymap: default_keymap(),
792        on_event: None,
793    }
794}
795
796impl<Id: fmt::Debug, Message> fmt::Debug for Command<'_, Id, Message> {
797    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
798        f.debug_struct("Command")
799            .field("state", self.state)
800            .field("placeholder", &self.placeholder)
801            .field("empty", &self.empty)
802            .field("loading", &self.loading)
803            .field("enabled", &self.on_event.is_some())
804            .finish_non_exhaustive()
805    }
806}
807
808impl<'a, Id, Message> Command<'a, Id, Message> {
809    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
810        self.placeholder = placeholder.into();
811        self
812    }
813
814    /// Text shown when nothing matches the query.
815    pub fn empty(mut self, empty: impl Into<String>) -> Self {
816        self.empty = empty.into();
817        self
818    }
819
820    /// Shows that a background search is still running.
821    pub fn loading(mut self, loading: bool) -> Self {
822        self.loading = loading;
823        self
824    }
825
826    /// Overrides the width. Fills the available width by default.
827    pub fn width(mut self, width: impl Into<Length>) -> Self {
828        self.width = width.into();
829        self
830    }
831
832    /// Overrides the height. Fits the results by default; a fixed height
833    /// stops the list resizing as the results change.
834    pub fn height(mut self, height: impl Into<Length>) -> Self {
835        self.height = height.into();
836        self
837    }
838
839    /// Caps the height in pixels while the list still shrinks to fit a
840    /// few results. Rows past the cap are clipped, so keep
841    /// [`State::with_visible_rows`] within it.
842    pub fn max_height(mut self, max_height: f32) -> Self {
843        self.max_height = max_height;
844        self
845    }
846
847    /// Sets the widget id of the search field, to focus it or find it in
848    /// tests.
849    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
850        self.id = Some(id.into());
851        self
852    }
853
854    /// Replaces the [`default_keymap`].
855    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
856        self.keymap = keymap;
857        self
858    }
859
860    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
861        self.on_event = Some(Box::new(on_event));
862        self
863    }
864
865    pub fn is_enabled(&self) -> bool {
866        self.on_event.is_some()
867    }
868}
869
870impl<'a, Id, Message> From<Command<'a, Id, Message>> for Element<'a, Message>
871where
872    Id: 'a,
873    Message: Clone + 'a,
874{
875    fn from(command: Command<'a, Id, Message>) -> Self {
876        let Command {
877            state,
878            placeholder,
879            empty,
880            loading,
881            width,
882            height,
883            max_height,
884            id,
885            keymap,
886            on_event,
887        } = command;
888
889        let on_event: Option<Rc<dyn Fn(Event<Id>) -> Message + 'a>> = on_event.map(Rc::from);
890        let list = list(state, &empty, loading, on_event.as_deref());
891        let list = container(list).height(if height == Length::Shrink {
892            Length::Shrink
893        } else {
894            Length::Fill
895        });
896        let on_input = on_event.clone().map(|on_event| {
897            Box::new(move |query| on_event(Event::Input(query)))
898                as Box<dyn Fn(String) -> Message + 'a>
899        });
900
901        let surface = container(column![
902            search_field(&placeholder, &state.query, id, on_input),
903            container(widget::space())
904                .width(Length::Fill)
905                .height(1)
906                .style(|theme| divider_style(&Tokens::of(theme))),
907            list,
908        ])
909        .width(width)
910        .height(height)
911        .max_height(max_height)
912        .clip(true)
913        .style(|theme| surface_style(&Tokens::of(theme)));
914
915        let Some(on_event) = on_event else {
916            return surface.into();
917        };
918        let on_focus = on_event.clone();
919        anchored(surface)
920            .dismiss_keys([])
921            .on_focus(move |focused| on_focus(Event::Focus(focused)))
922            .on_key(move |key| {
923                keymap
924                    .resolve_event(key)
925                    .and_then(|action| action.event(state))
926                    .map(&*on_event)
927            })
928            .into()
929    }
930}
931
932fn search_field<'a, Message: Clone + 'a>(
933    placeholder: &str,
934    value: &str,
935    id: Option<widget::Id>,
936    on_input: Option<impl Fn(String) -> Message + 'a>,
937) -> Element<'a, Message> {
938    let enabled = on_input.is_some();
939    let vertical = (SEARCH_HEIGHT - text_size::SM * LINE_HEIGHT) / 2.0;
940    let mut input = widget::text_input(placeholder, value)
941        .size(text_size::SM)
942        .line_height(LineHeight::Relative(LINE_HEIGHT))
943        .padding(Padding {
944            top: vertical,
945            bottom: vertical,
946            left: space::MD + ICON_SIZE + space::SM,
947            right: space::MD,
948        })
949        .width(Length::Fill)
950        .style(|theme, status| search_style(&Tokens::of(theme), status));
951    if let Some(id) = id {
952        input = input.id(id);
953    }
954    if let Some(on_input) = on_input {
955        input = input.on_input(on_input);
956    }
957
958    let icon = themed(
959        crate::lucide!(Search),
960        menu::ICON_SIZE,
961        opacity(enabled),
962        |theme| Tokens::of(theme).muted_foreground,
963    );
964
965    stack![
966        input,
967        container(icon)
968            .height(Length::Fill)
969            .align_y(Alignment::Center)
970            .padding(Padding::ZERO.left(space::MD)),
971    ]
972    .into()
973}
974
975fn list<'a, Id, Message: Clone + 'a>(
976    state: &'a State<Id>,
977    empty: &str,
978    loading: bool,
979    on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
980) -> Element<'a, Message> {
981    let mut children: Vec<Element<'a, Message>> = Vec::new();
982    let mut previous: Option<(bool, usize)> = None;
983
984    for position in state.visible() {
985        let Some(&entry) = state.entries.get(position) else {
986            continue;
987        };
988        let Some(group) = state.group(entry) else {
989            continue;
990        };
991        let Some(item) = group.items.get(entry.item) else {
992            continue;
993        };
994
995        let key = (entry.remote, entry.group);
996        if previous != Some(key) {
997            if previous.is_some() {
998                children.push(menu::separator());
999            }
1000            if !group.label.is_empty() {
1001                children.push(menu::group_label(&group.label, false));
1002            }
1003            previous = Some(key);
1004        }
1005
1006        let enabled = on_event.is_some() && !item.disabled;
1007        let status = if !enabled {
1008            RowStatus::Disabled
1009        } else if state.shows_highlight() && state.highlighted == Some(position) {
1010            RowStatus::Highlighted
1011        } else {
1012            RowStatus::Idle
1013        };
1014        let messages = on_event.filter(|_| enabled).map(|on_event| {
1015            (
1016                on_event(Event::Highlight(position)),
1017                on_event(Event::Activate(position)),
1018            )
1019        });
1020        let parts = Parts {
1021            icon: item.icon,
1022            hint: item.shortcut.as_deref(),
1023            ..Parts::label(&item.label)
1024        };
1025        children.push(menu::item(parts, status, item.destructive, messages));
1026    }
1027
1028    if children.is_empty() {
1029        return menu::empty(if loading { SEARCHING } else { empty });
1030    }
1031    if loading {
1032        children.push(menu::inset(
1033            container(muted(SEARCHING.to_owned(), text_size::SM)).padding(ROW_PADDING),
1034        ));
1035    }
1036
1037    let rows = Column::from_vec(children)
1038        .padding([space::XS, 0.0])
1039        .width(Length::Fill);
1040    let Some(on_event) = on_event else {
1041        return rows.into();
1042    };
1043    let down = on_event(Event::Scroll(1));
1044    let up = on_event(Event::Scroll(-1));
1045    mouse_area(rows)
1046        .on_scroll(move |delta| match delta {
1047            ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } if y < 0.0 => down.clone(),
1048            _ => up.clone(),
1049        })
1050        .into()
1051}
1052
1053fn muted<'a, Message: 'a>(content: String, size: f32) -> Element<'a, Message> {
1054    text(content)
1055        .size(size)
1056        .line_height(LineHeight::Absolute(menu::LINE_HEIGHT.into()))
1057        .style(|theme: &Theme| text::Style {
1058            color: Some(Tokens::of(theme).muted_foreground),
1059        })
1060        .into()
1061}
1062
1063/// The search field: borderless and transparent, inside the surface.
1064pub fn search_style(tokens: &Tokens, status: Status) -> text_input::Style {
1065    let style = text_input::Style {
1066        background: Background::Color(Color::TRANSPARENT),
1067        border: Border::default(),
1068        icon: tokens.muted_foreground,
1069        placeholder: tokens.muted_foreground,
1070        value: tokens.foreground,
1071        selection: fade(tokens.primary, 0.25),
1072    };
1073    if status != Status::Disabled {
1074        return style;
1075    }
1076    text_input::Style {
1077        placeholder: fade(style.placeholder, 0.5),
1078        value: fade(style.value, 0.5),
1079        icon: fade(style.icon, 0.5),
1080        selection: Color::TRANSPARENT,
1081        ..style
1082    }
1083}
1084
1085/// The line under the search field and between groups.
1086pub fn divider_style(tokens: &Tokens) -> container::Style {
1087    container::Style {
1088        background: Some(Background::Color(tokens.border)),
1089        ..container::Style::default()
1090    }
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use crate::theme::{dark, light};
1097    use futures::SinkExt;
1098    use futures::executor::block_on;
1099
1100    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1101        let chord: Chord = chord.parse().unwrap();
1102        keymap.resolve(chord.key(), chord.modifiers())
1103    }
1104
1105    fn sample() -> State<&'static str> {
1106        State::new([
1107            group(
1108                "Suggestions",
1109                [
1110                    item("calendar", "Calendar"),
1111                    item("emoji", "Search emoji").keywords(["smiley"]),
1112                    item("calculator", "Calculator").disabled(true),
1113                ],
1114            ),
1115            group(
1116                "Settings",
1117                [
1118                    item("profile", "Profile").shortcut("Ctrl+P"),
1119                    item("billing", "Billing"),
1120                    item("settings", "Settings"),
1121                ],
1122            ),
1123        ])
1124    }
1125
1126    fn ids(state: &State<&'static str>) -> Vec<&'static str> {
1127        state.results().map(|(_, item)| item.id).collect()
1128    }
1129
1130    fn highlighted(state: &State<&'static str>) -> Option<&'static str> {
1131        state.highlighted().map(|item| item.id)
1132    }
1133
1134    #[test]
1135    fn empty_query_matches_everything_equally() {
1136        assert_eq!(score("", "Anything"), Some(0));
1137        assert_eq!(score("   ", "Anything"), Some(0));
1138    }
1139
1140    #[test]
1141    fn matching_ignores_case() {
1142        assert_eq!(score("SET", "settings"), score("set", "Settings"));
1143        assert!(score("cAl", "CALENDAR").is_some());
1144    }
1145
1146    #[test]
1147    fn match_kinds_rank_in_order() {
1148        let exact = score("set", "Set");
1149        let prefix = score("set", "Settings");
1150        let word = score("set", "Reset settings");
1151        let contains = score("set", "Asset");
1152        let fuzzy = score("set", "Sheet editor");
1153        assert!(exact > prefix, "{exact:?} {prefix:?}");
1154        assert!(prefix > word, "{prefix:?} {word:?}");
1155        assert!(word > contains, "{word:?} {contains:?}");
1156        assert!(contains > fuzzy, "{contains:?} {fuzzy:?}");
1157        assert!(fuzzy.is_some());
1158    }
1159
1160    #[test]
1161    fn letters_must_appear_in_order() {
1162        assert!(score("tes", "Settings").is_none());
1163        assert!(score("xyz", "Settings").is_none());
1164        assert!(score("settings!", "Settings").is_none());
1165    }
1166
1167    #[test]
1168    fn shorter_texts_win_within_a_kind() {
1169        assert!(score("set", "Settings") > score("set", "Settings and privacy"));
1170    }
1171
1172    #[test]
1173    fn fuzzy_prefers_word_starts_and_runs() {
1174        assert!(score("ts", "Toggle sidebar") > score("ts", "Tabs list"));
1175        assert!(score("nf", "newFile") > score("nf", "notify"));
1176        assert!(score("sba", "Show bar") > score("sba", "Toggle sidebar"));
1177        assert!(score("ogl", "toggle") > score("ogl", "tobgaly"));
1178    }
1179
1180    #[test]
1181    fn keywords_find_items_but_rank_below_labels() {
1182        let emoji = item("emoji", "Search emoji").keywords(["smiley"]);
1183        assert!(emoji.score("smil").is_some());
1184        let smiley = item("smiley", "Smiley");
1185        assert!(smiley.score("smil") > emoji.score("smil"));
1186        assert!(item("x", "Plain").score("smil").is_none());
1187    }
1188
1189    #[test]
1190    fn new_state_lists_every_item_and_highlights_the_first() {
1191        let state = sample();
1192        assert_eq!(state.len(), 6);
1193        assert_eq!(highlighted(&state), Some("calendar"));
1194        assert_eq!(state.query(), "");
1195        assert!(State::<u8>::new([]).is_empty());
1196        assert!(State::<u8>::new([]).highlighted().is_none());
1197    }
1198
1199    #[test]
1200    fn the_highlight_shows_only_while_focused_or_after_navigating() {
1201        let mut state = sample();
1202        assert!(!state.shows_highlight());
1203        assert!(!state.is_focused());
1204
1205        let _ = state.update(Event::Focus(true));
1206        assert!(state.shows_highlight() && state.is_focused());
1207        let _ = state.update(Event::Focus(false));
1208        assert!(!state.shows_highlight());
1209
1210        for event in [
1211            Event::Next,
1212            Event::Previous,
1213            Event::First,
1214            Event::Last,
1215            Event::Highlight(0),
1216        ] {
1217            let mut state = sample();
1218            let _ = state.update(event);
1219            assert!(state.shows_highlight());
1220            let _ = state.update(Event::Focus(true));
1221            let _ = state.update(Event::Focus(false));
1222            assert!(!state.shows_highlight(), "leaving the field hides it again");
1223        }
1224    }
1225
1226    #[test]
1227    fn typing_or_scrolling_alone_does_not_show_the_highlight() {
1228        let mut state = sample();
1229        let _ = state.update(Event::Input("set".into()));
1230        let _ = state.update(Event::Scroll(1));
1231        assert!(!state.shows_highlight());
1232        assert!(state.highlighted().is_some(), "Enter still has a target");
1233    }
1234
1235    #[test]
1236    fn typing_filters_ranks_and_asks_for_a_search() {
1237        let mut state = sample();
1238        let output = state.update(Event::Input("cal".into()));
1239        assert!(matches!(output, Some(Output::Search(query)) if query == "cal"));
1240        assert_eq!(ids(&state), ["calendar", "calculator"]);
1241        assert_eq!(highlighted(&state), Some("calendar"));
1242
1243        // Calculator is the closer match now, but it is disabled.
1244        let _ = state.update(Event::Input("calc".into()));
1245        assert_eq!(ids(&state), ["calculator"]);
1246        assert!(state.highlighted().is_none());
1247
1248        let _ = state.update(Event::Input("set".into()));
1249        assert_eq!(ids(&state), ["settings"]);
1250        let groups: Vec<_> = state.results().map(|(group, _)| group).collect();
1251        assert_eq!(groups, ["Settings"]);
1252
1253        assert!(state.update(Event::Input("set".into())).is_none());
1254    }
1255
1256    #[test]
1257    fn nothing_matching_leaves_nothing_highlighted() {
1258        let mut state = sample();
1259        let _ = state.update(Event::Input("zzz".into()));
1260        assert!(state.is_empty());
1261        assert!(state.highlighted().is_none());
1262        assert!(state.update(Event::ActivateHighlighted).is_none());
1263        let _ = state.update(Event::Next);
1264        assert!(state.highlighted().is_none());
1265    }
1266
1267    #[test]
1268    fn arrows_wrap_and_skip_disabled_items() {
1269        let mut state = sample();
1270        let _ = state.update(Event::Next);
1271        assert_eq!(highlighted(&state), Some("emoji"));
1272        let _ = state.update(Event::Next);
1273        assert_eq!(highlighted(&state), Some("profile"));
1274        let _ = state.update(Event::Previous);
1275        let _ = state.update(Event::Previous);
1276        let _ = state.update(Event::Previous);
1277        assert_eq!(highlighted(&state), Some("settings"));
1278        let _ = state.update(Event::Next);
1279        assert_eq!(highlighted(&state), Some("calendar"));
1280    }
1281
1282    #[test]
1283    fn typing_highlights_the_best_match_in_any_group() {
1284        let mut state = State::new([
1285            group(
1286                "Actions",
1287                [
1288                    item("retry", "Retry failed jobs"),
1289                    item("clear", "Clear finished jobs").destructive(true),
1290                ],
1291            ),
1292            group(
1293                "Open job",
1294                [
1295                    item("ledger", "ledger-reconcile"),
1296                    item("lead", "lead-import").disabled(true),
1297                ],
1298            ),
1299        ]);
1300        let _ = state.update(Event::Input("led".into()));
1301        assert_eq!(
1302            ids(&state)[..2],
1303            ["retry", "clear"],
1304            "groups keep their order"
1305        );
1306        assert_eq!(highlighted(&state), Some("ledger"));
1307
1308        let _ = state.update(Event::Input(String::new()));
1309        assert_eq!(highlighted(&state), Some("retry"), "no query, first row");
1310    }
1311
1312    #[test]
1313    fn home_and_end_jump_to_enabled_ends() {
1314        let mut state = State::new([group(
1315            "",
1316            [
1317                item(1, "One").disabled(true),
1318                item(2, "Two"),
1319                item(3, "Three"),
1320                item(4, "Four").disabled(true),
1321            ],
1322        )]);
1323        let _ = state.update(Event::Last);
1324        assert_eq!(state.highlighted().map(|i| i.id), Some(3));
1325        let _ = state.update(Event::First);
1326        assert_eq!(state.highlighted().map(|i| i.id), Some(2));
1327    }
1328
1329    #[test]
1330    fn run_and_activate_return_the_id() {
1331        let mut state = sample();
1332        let _ = state.update(Event::Next);
1333        assert!(matches!(
1334            state.update(Event::ActivateHighlighted),
1335            Some(Output::Activated("emoji"))
1336        ));
1337
1338        assert!(matches!(
1339            state.update(Event::Activate(4)),
1340            Some(Output::Activated("billing"))
1341        ));
1342        assert_eq!(highlighted(&state), Some("billing"));
1343        assert!(state.update(Event::Activate(2)).is_none());
1344        assert!(state.update(Event::Activate(99)).is_none());
1345    }
1346
1347    #[test]
1348    fn hovering_a_disabled_row_keeps_the_highlight() {
1349        let mut state = sample();
1350        let _ = state.update(Event::Highlight(2));
1351        assert_eq!(highlighted(&state), Some("calendar"));
1352        let _ = state.update(Event::Highlight(3));
1353        assert_eq!(highlighted(&state), Some("profile"));
1354    }
1355
1356    #[test]
1357    fn clear_empties_the_query_then_dismisses() {
1358        let mut state = sample();
1359        let _ = state.update(Event::Input("bill".into()));
1360        let output = state.update(Event::Close);
1361        assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
1362        assert_eq!(state.len(), 6);
1363        assert!(matches!(state.update(Event::Close), Some(Output::Closed)));
1364    }
1365
1366    #[test]
1367    fn results_for_the_current_query_join_a_remote_group() {
1368        let mut state = sample();
1369        let _ = state.update(Event::Input("rep".into()));
1370        assert!(state.is_empty());
1371
1372        let _ = state.update(Event::Received(vec![
1373            results("rep", "Files", [item("a", "report.pdf")]),
1374            results("rep", "Files", [item("b", "reply.txt")]),
1375        ]));
1376        assert_eq!(ids(&state), ["a", "b"]);
1377        assert!(state.results().all(|(group, _)| group == "Files"));
1378        assert_eq!(highlighted(&state), Some("a"));
1379    }
1380
1381    #[test]
1382    fn stale_results_are_dropped() {
1383        let mut state = sample();
1384        let _ = state.update(Event::Input("re".into()));
1385        let _ = state.update(Event::Input("rep".into()));
1386        let _ = state.update(Event::Received(vec![results(
1387            "re",
1388            "Files",
1389            [item("old", "readme.md")],
1390        )]));
1391        assert!(state.is_empty());
1392    }
1393
1394    #[test]
1395    fn a_new_query_clears_remote_results() {
1396        let mut state = sample();
1397        let files = |state: &State<&'static str>| {
1398            state
1399                .results()
1400                .filter(|(group, _)| *group == "Files")
1401                .count()
1402        };
1403        let _ = state.update(Event::Input("re".into()));
1404        let _ = state.update(Event::Received(vec![results(
1405            "re",
1406            "Files",
1407            [item("old", "readme.md")],
1408        )]));
1409        assert_eq!(files(&state), 1);
1410        let _ = state.update(Event::Input("rea".into()));
1411        assert_eq!(files(&state), 0);
1412    }
1413
1414    #[test]
1415    fn arriving_results_keep_the_highlight() {
1416        let mut state = sample();
1417        let _ = state.update(Event::Input("s".into()));
1418        let _ = state.update(Event::Next);
1419        let before = highlighted(&state);
1420        let _ = state.update(Event::Received(vec![results(
1421            "s",
1422            "Files",
1423            [item("f", "styles.css")],
1424        )]));
1425        assert_eq!(highlighted(&state), before);
1426        assert_eq!(state.results().last().map(|(_, item)| item.id), Some("f"));
1427    }
1428
1429    #[test]
1430    fn remote_results_are_not_filtered_again() {
1431        let mut state = sample();
1432        let _ = state.update(Event::Input("q".into()));
1433        let _ = state.update(Event::Received(vec![results(
1434            "q",
1435            "Files",
1436            [item("x", "unrelated.txt")],
1437        )]));
1438        assert_eq!(ids(&state), ["x"]);
1439    }
1440
1441    #[test]
1442    fn the_window_follows_the_highlight_and_scrolls() {
1443        let mut state = sample().with_visible_rows(3);
1444        assert_eq!(state.visible(), 0..3);
1445        let _ = state.update(Event::Last);
1446        assert_eq!(state.visible(), 3..6);
1447        let _ = state.update(Event::Next);
1448        assert_eq!(state.visible(), 0..3);
1449        let _ = state.update(Event::Scroll(10));
1450        assert_eq!(state.visible(), 3..6);
1451        let _ = state.update(Event::Scroll(-1));
1452        assert_eq!(state.visible(), 2..5);
1453        assert_eq!(State::<u8>::new([]).with_visible_rows(0).rows, 1);
1454        assert_eq!(State::<u8>::new([]).visible(), 0..0);
1455    }
1456
1457    #[test]
1458    fn ready_hands_the_sender_back() {
1459        let (sender, _receiver) = mpsc::channel(1);
1460        let output = sample().update(Event::Ready(sender));
1461        assert!(matches!(output, Some(Output::Ready(_))));
1462    }
1463
1464    #[test]
1465    fn default_keymap_and_overrides() {
1466        let keymap = default_keymap();
1467        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1468        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1469        assert_eq!(press(&keymap, "Home"), Some(Action::First));
1470        assert_eq!(press(&keymap, "End"), Some(Action::Last));
1471        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1472        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1473
1474        let custom = keymap
1475            .bind("Ctrl+N".parse().unwrap(), Action::Next)
1476            .unbind(&Chord::named(Named::Home));
1477        assert_eq!(press(&custom, "ctrl+n"), Some(Action::Next));
1478        assert_eq!(press(&custom, "Home"), None);
1479        assert!(default_keymap().clear().is_empty());
1480    }
1481
1482    #[test]
1483    fn actions_map_to_events_only_when_they_apply() {
1484        let mut state = sample();
1485        for action in [Action::Next, Action::Previous, Action::First, Action::Last] {
1486            assert!(action.event(&state).is_some(), "{action:?}");
1487        }
1488        assert!(matches!(
1489            Action::Activate.event(&state),
1490            Some(Event::ActivateHighlighted)
1491        ));
1492        assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1493
1494        let _ = state.update(Event::Input("zzz".into()));
1495        for action in [
1496            Action::Next,
1497            Action::Previous,
1498            Action::First,
1499            Action::Last,
1500            Action::Activate,
1501        ] {
1502            assert!(action.event(&state).is_none(), "{action:?}");
1503        }
1504        assert!(matches!(Action::Close.event(&state), Some(Event::Close)));
1505    }
1506
1507    #[test]
1508    fn stream_emits_ready_first_then_batches() {
1509        let mut events = Box::pin(stream::<&'static str>());
1510        let Some(Event::Ready(mut sender)) = block_on(events.next()) else {
1511            panic!("first event must be Ready");
1512        };
1513        block_on(async {
1514            for name in ["a", "b", "c"] {
1515                sender
1516                    .send(results("q", "Files", [item(name, name)]))
1517                    .await
1518                    .ok();
1519            }
1520        });
1521        let Some(Event::Received(batch)) = block_on(events.next()) else {
1522            panic!("expected a batch");
1523        };
1524        let names: Vec<_> = batch.iter().map(|r| r.items[0].id).collect();
1525        assert_eq!(names, ["a", "b", "c"]);
1526    }
1527
1528    #[test]
1529    fn batches_split_bursts_at_batch_size() {
1530        let items = stream::iter(0..BATCH_SIZE + 3);
1531        let sizes: Vec<_> = block_on(batches(items).map(|batch| batch.len()).collect());
1532        assert_eq!(sizes, [BATCH_SIZE, 3]);
1533    }
1534
1535    #[test]
1536    fn default_builder_is_disabled() {
1537        let state = sample();
1538        let c: Command<'_, &str, ()> = command(&state);
1539        assert!(!c.is_enabled());
1540        assert_eq!(c.empty, DEFAULT_EMPTY);
1541        assert!(!c.loading);
1542        assert_eq!((c.width, c.height), (Length::Fill, Length::Shrink));
1543        assert_eq!(c.max_height, f32::INFINITY);
1544        let c = c
1545            .loading(true)
1546            .height(320)
1547            .max_height(400.0)
1548            .on_event(|_| ());
1549        assert!(c.is_enabled() && c.loading);
1550        assert_eq!(c.height, Length::Fixed(320.0));
1551        assert_eq!(c.max_height, 400.0);
1552    }
1553
1554    const STATES: [Status; 5] = [
1555        Status::Active,
1556        Status::Hovered,
1557        Status::Focused { is_hovered: false },
1558        Status::Focused { is_hovered: true },
1559        Status::Disabled,
1560    ];
1561
1562    #[test]
1563    fn search_field_is_borderless_and_fades_when_disabled() {
1564        for theme in [light(), dark()] {
1565            let tokens = Tokens::of(&theme);
1566            for status in STATES {
1567                let style = search_style(&tokens, status);
1568                assert_eq!(style.border.width, 0.0);
1569                assert_eq!(style.background, Background::Color(Color::TRANSPARENT));
1570                let expected = if status == Status::Disabled {
1571                    fade(tokens.foreground, 0.5)
1572                } else {
1573                    tokens.foreground
1574                };
1575                assert_eq!(style.value, expected, "{status:?}");
1576            }
1577        }
1578    }
1579
1580    #[test]
1581    fn dividers_use_the_border_token() {
1582        for theme in [light(), dark()] {
1583            let tokens = Tokens::of(&theme);
1584            assert_eq!(
1585                divider_style(&tokens).background,
1586                Some(Background::Color(tokens.border))
1587            );
1588        }
1589    }
1590}