Skip to main content

iced_cube/application/
command_palette.rs

1//! The global searchable launcher: a [command list](mod@crate::navigation::command)
2//! in a [dialog](mod@crate::overlay::dialog), opened from anywhere with Ctrl+K
3//! or Ctrl+Shift+P.
4//!
5//! [`State`] owns whether the palette is open, its groups of commands, any
6//! nested [`Page`]s and the recently run commands, which it lists first
7//! while the query is empty. Choosing an item whose id names a page, such
8//! as "Change theme...", opens that page's list instead of running it;
9//! Backspace on an empty query, Escape or the Back button returns to the
10//! page before.
11//!
12//! The app routes key presses from [`keys::subscription`] through
13//! [`State::key_event`] so the toggle chords open the palette anywhere.
14//! While it is open, the palette resolves its own keys: the command
15//! list's first, then Back and the toggle chords, then the dialog's. Async
16//! results arrive through the command list's channel: [`subscription`]
17//! wraps [`command::subscription`], and [`Output::Search`] asks the app to
18//! search the current page for a query.
19//!
20//! ```no_run
21//! use iced::{Element, Subscription};
22//! use iced::widget::text;
23//! use iced_cube::command_palette::{self, Output, command_palette, page};
24//! use iced_cube::keys;
25//! use iced_cube::navigation::command::{group, item};
26//!
27//! #[derive(Debug, Clone)]
28//! enum Message {
29//!     Palette(command_palette::Event<&'static str>),
30//!     Key(keys::Event),
31//! }
32//!
33//! struct App {
34//!     palette: command_palette::State<&'static str>,
35//! }
36//!
37//! impl App {
38//!     fn new() -> Self {
39//!         let palette = command_palette::State::new([group(
40//!             "Actions",
41//!             [item("new", "New file"), item("theme", "Change theme...")],
42//!         )])
43//!         .with_page(page("theme", "Theme", [group("", [item("dark", "Dark"), item("light", "Light")])]));
44//!         Self { palette }
45//!     }
46//!
47//!     fn update(&mut self, message: Message) {
48//!         let event = match message {
49//!             Message::Palette(event) => Some(event),
50//!             Message::Key(key) => self
51//!                 .palette
52//!                 .key_event(&command_palette::default_keymap(), &key),
53//!         };
54//!         if let Some(Output::Activated(id)) = event.and_then(|event| self.palette.update(event)) {
55//!             println!("Run {id}");
56//!         }
57//!     }
58//!
59//!     fn view(&self) -> Element<'_, Message> {
60//!         command_palette(&self.palette, text("Press Ctrl+K"))
61//!             .on_event(Message::Palette)
62//!             .into()
63//!     }
64//!
65//!     fn subscription(&self) -> Subscription<Message> {
66//!         keys::subscription().map(Message::Key)
67//!     }
68//! }
69//! ```
70
71use std::rc::Rc;
72
73use iced::keyboard::key::Named;
74use iced::widget;
75use iced::{Element, Subscription};
76
77use crate::keys::{self, Chord, Keymap};
78use crate::navigation::command::{self, Group, Item, command, group};
79use crate::overlay::anchored::anchored;
80use crate::overlay::dialog::{self, dialog};
81use crate::primitives::button::{self, Variant, button};
82
83/// The widget id of the search field, to focus it or find it in tests.
84pub const SEARCH_ID: widget::Id = widget::Id::new("iced-cube-command-palette-search");
85/// The widget id of the dialog surface, which scopes its focus.
86pub const DIALOG_ID: widget::Id = widget::Id::new("iced-cube-command-palette");
87/// How many recent commands are kept unless the state sets its own number.
88pub const RECENT_LIMIT: usize = 5;
89/// The heading over the recent commands.
90pub const RECENT_LABEL: &str = "Recent";
91/// How many rows show at once unless the state sets its own number.
92pub const VISIBLE_ROWS: usize = 8;
93/// The dialog title on the first page unless the builder sets its own.
94pub const DEFAULT_TITLE: &str = "Command palette";
95
96/// A nested list, opened by choosing the item with the page's id.
97#[derive(Debug, Clone, PartialEq)]
98pub struct Page<Id> {
99    pub id: Id,
100    /// Shown as the dialog title while the page is open.
101    pub title: String,
102    /// Replaces the search field's placeholder while the page is open.
103    pub placeholder: Option<String>,
104    pub groups: Vec<Group<Id>>,
105}
106
107/// A page listing `groups`, opened by the item whose id is `id`.
108pub fn page<Id>(
109    id: Id,
110    title: impl Into<String>,
111    groups: impl IntoIterator<Item = Group<Id>>,
112) -> Page<Id> {
113    Page {
114        id,
115        title: title.into(),
116        placeholder: None,
117        groups: groups.into_iter().collect(),
118    }
119}
120
121impl<Id> Page<Id> {
122    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
123        self.placeholder = Some(placeholder.into());
124        self
125    }
126}
127
128/// Changes to a command palette.
129#[derive(Debug, Clone)]
130pub enum Event<Id> {
131    /// Opens on the first page with an empty query.
132    Open,
133    Close,
134    Toggle,
135    /// Returns to the page before, if a nested page is open.
136    Back,
137    /// An event for the command list inside.
138    Command(command::Event<Id>),
139}
140
141/// What the app may need to act on after an [`Event`].
142#[derive(Debug, Clone)]
143pub enum Output<Id> {
144    /// The results channel is open. Keep the sender for producers.
145    Ready(command::Sender<Id>),
146    /// The query changed. Search [`State::page_id`] for it in the
147    /// background, if anything is searched that way.
148    Search(String),
149    /// A command was chosen. The palette has closed and noted it as recent.
150    Activated(Id),
151}
152
153/// Whether the palette is open, its pages, the recent commands and the
154/// command list on show.
155#[derive(Debug, Clone, PartialEq)]
156pub struct State<Id> {
157    open: bool,
158    root: Vec<Group<Id>>,
159    pages: Vec<Page<Id>>,
160    stack: Vec<Id>,
161    recent: Vec<Id>,
162    recent_limit: usize,
163    rows: usize,
164    command: command::State<Id>,
165}
166
167impl<Id: Clone + PartialEq> State<Id> {
168    /// A closed palette listing `groups` on its first page.
169    pub fn new(groups: impl IntoIterator<Item = Group<Id>>) -> Self {
170        let mut state = Self {
171            open: false,
172            root: groups.into_iter().collect(),
173            pages: Vec::new(),
174            stack: Vec::new(),
175            recent: Vec::new(),
176            recent_limit: RECENT_LIMIT,
177            rows: VISIBLE_ROWS,
178            command: command::State::new([]),
179        };
180        state.rebuild(String::new());
181        state
182    }
183
184    /// Adds a nested page. Choosing the item with the page's id opens it.
185    pub fn with_page(mut self, page: Page<Id>) -> Self {
186        self.pages.push(page);
187        self
188    }
189
190    /// Starts with these recent commands, newest first.
191    pub fn with_recent(mut self, ids: impl IntoIterator<Item = Id>) -> Self {
192        self.recent = ids.into_iter().take(self.recent_limit).collect();
193        self.rebuild(String::new());
194        self
195    }
196
197    /// How many recent commands to keep. Zero turns the recent group off.
198    pub fn with_recent_limit(mut self, limit: usize) -> Self {
199        self.recent_limit = limit;
200        self.recent.truncate(limit);
201        self.rebuild(String::new());
202        self
203    }
204
205    /// How many rows show at once. At least one does.
206    pub fn with_visible_rows(mut self, rows: usize) -> Self {
207        self.rows = rows.max(1);
208        self.rebuild(String::new());
209        self
210    }
211
212    pub fn is_open(&self) -> bool {
213        self.open
214    }
215
216    /// The nested page on show, or `None` on the first page.
217    pub fn page(&self) -> Option<&Page<Id>> {
218        let id = self.stack.last()?;
219        self.pages.iter().find(|page| page.id == *id)
220    }
221
222    /// The id of the nested page on show.
223    pub fn page_id(&self) -> Option<&Id> {
224        self.stack.last()
225    }
226
227    /// How many nested pages are open.
228    pub fn depth(&self) -> usize {
229        self.stack.len()
230    }
231
232    /// Recent commands, newest first.
233    pub fn recent(&self) -> &[Id] {
234        &self.recent
235    }
236
237    /// The command list on show, with its query, results and highlight.
238    pub fn command(&self) -> &command::State<Id> {
239        &self.command
240    }
241
242    pub fn query(&self) -> &str {
243        self.command.query()
244    }
245
246    /// Replaces the first page's groups, such as when a label changes.
247    pub fn set_groups(&mut self, groups: impl IntoIterator<Item = Group<Id>>) {
248        self.root = groups.into_iter().collect();
249        if self.stack.is_empty() {
250            self.rebuild(self.command.query().to_owned());
251        }
252    }
253
254    /// Applies an event and returns what the app may need to act on.
255    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
256        match event {
257            Event::Open => self.open(),
258            Event::Close => {
259                self.open = false;
260                None
261            }
262            Event::Toggle if self.open => {
263                self.open = false;
264                None
265            }
266            Event::Toggle => self.open(),
267            Event::Back => self.back(),
268            Event::Command(command::Event::Input(query)) => self.input(query),
269            Event::Command(event) => match self.command.update(event)? {
270                command::Output::Ready(sender) => Some(Output::Ready(sender)),
271                command::Output::Search(query) => Some(Output::Search(query)),
272                command::Output::Activated(id) => self.activate(id),
273                command::Output::Closed if self.stack.is_empty() => {
274                    self.open = false;
275                    None
276                }
277                command::Output::Closed => self.back(),
278            },
279        }
280    }
281
282    /// Turns a key press into an event: a toggle chord at any time, and
283    /// Back only while a nested page is open with an empty query, so
284    /// Backspace still deletes text.
285    pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event<Id>> {
286        keymap
287            .resolve_event(key)
288            .and_then(|action| action.event(self))
289    }
290
291    fn open(&mut self) -> Option<Output<Id>> {
292        let had_query = !self.command.query().is_empty();
293        self.open = true;
294        self.stack.clear();
295        self.rebuild(String::new());
296        had_query.then(|| Output::Search(String::new()))
297    }
298
299    fn back(&mut self) -> Option<Output<Id>> {
300        self.stack.pop()?;
301        let had_query = !self.command.query().is_empty();
302        self.rebuild(String::new());
303        had_query.then(|| Output::Search(String::new()))
304    }
305
306    fn input(&mut self, query: String) -> Option<Output<Id>> {
307        let shows_recent = self.stack.is_empty() && !self.recent.is_empty();
308        let was_empty = self.command.query().is_empty();
309        if shows_recent && was_empty != query.is_empty() {
310            self.rebuild(query.clone());
311            return Some(Output::Search(query));
312        }
313        match self.command.update(command::Event::Input(query))? {
314            command::Output::Search(query) => Some(Output::Search(query)),
315            _ => None,
316        }
317    }
318
319    fn activate(&mut self, id: Id) -> Option<Output<Id>> {
320        if self.pages.iter().any(|page| page.id == id) {
321            self.stack.push(id);
322            self.rebuild(String::new());
323            return None;
324        }
325        self.remember(id.clone());
326        self.open = false;
327        Some(Output::Activated(id))
328    }
329
330    /// Puts `id` first among the recent commands.
331    fn remember(&mut self, id: Id) {
332        self.recent.retain(|recent| *recent != id);
333        self.recent.insert(0, id);
334        self.recent.truncate(self.recent_limit);
335    }
336
337    /// The groups the list shows for the page on show: with the recent
338    /// commands first on the first page while the query is empty.
339    fn groups(&self, query: &str) -> Vec<Group<Id>> {
340        let Some(id) = self.stack.last() else {
341            let mut groups = Vec::with_capacity(self.root.len() + 1);
342            let recent: Vec<Item<Id>> = self
343                .recent
344                .iter()
345                .filter_map(|id| self.find(id).cloned())
346                .collect();
347            if query.is_empty() && !recent.is_empty() {
348                groups.push(group(RECENT_LABEL, recent));
349            }
350            groups.extend(self.root.iter().cloned());
351            return groups;
352        };
353        self.pages
354            .iter()
355            .find(|page| page.id == *id)
356            .map(|page| page.groups.clone())
357            .unwrap_or_default()
358    }
359
360    /// The first item with `id` on any page.
361    fn find(&self, id: &Id) -> Option<&Item<Id>> {
362        self.root
363            .iter()
364            .chain(self.pages.iter().flat_map(|page| &page.groups))
365            .flat_map(|group| &group.items)
366            .find(|item| item.id == *id)
367    }
368
369    /// Replaces the command list with the page on show, keeping focus.
370    fn rebuild(&mut self, query: String) {
371        let focused = self.command.is_focused();
372        self.command = command::State::new(self.groups(&query)).with_visible_rows(self.rows);
373        if !query.is_empty() {
374            let _ = self.command.update(command::Event::Input(query));
375        }
376        if focused {
377            let _ = self.command.update(command::Event::Focus(true));
378        }
379    }
380}
381
382/// Owns the command list's results channel. Emits [`command::Event::Ready`]
383/// first, then batches, wrapped in [`Event::Command`].
384pub fn subscription<Id: Send + 'static>() -> Subscription<Event<Id>> {
385    command::subscription().map(Event::Command)
386}
387
388/// What a command palette keyboard shortcut does.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
390pub enum Action {
391    /// Opens the palette, or closes it while it is open.
392    Toggle,
393    /// Returns to the page before.
394    Back,
395}
396
397impl Action {
398    /// The [`Event`] this action sends to `state`, or `None` when it would
399    /// do nothing, so the key reaches the search field instead.
400    pub fn event<Id: Clone + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
401        match self {
402            Action::Toggle => Some(Event::Toggle),
403            Action::Back => {
404                let back = state.open && !state.stack.is_empty() && state.query().is_empty();
405                back.then_some(Event::Back)
406            }
407        }
408    }
409}
410
411impl keys::Action for Action {
412    const ALL: &'static [Self] = &[Action::Toggle, Action::Back];
413
414    fn defaults() -> Keymap<Self> {
415        default_keymap()
416    }
417
418    fn name(self) -> &'static str {
419        match self {
420            Action::Toggle => "Toggle",
421            Action::Back => "Back",
422        }
423    }
424
425    fn description(self) -> &'static str {
426        match self {
427            Action::Toggle => "Opens the palette from anywhere, or closes it.",
428            Action::Back => "Returns to the page before, while the query is empty.",
429        }
430    }
431}
432
433/// The default command palette shortcuts:
434///
435/// | Keys | Action |
436/// | --- | --- |
437/// | `Ctrl+K`, `Ctrl+Shift+P` | [`Action::Toggle`] |
438/// | `Backspace` | [`Action::Back`] |
439///
440/// Ctrl is Cmd on macOS. Route presses from [`keys::subscription`] through
441/// [`State::key_event`] so the toggle works anywhere; the open dialog lets
442/// the toggle chords through to the app. Back only claims Backspace on a
443/// nested page with an empty query. The command list inside keeps its own
444/// keys (arrows, Enter and Escape) through [`CommandPalette::command_keymap`].
445pub fn default_keymap() -> Keymap<Action> {
446    Keymap::new()
447        .bind(Chord::character('k').command(), Action::Toggle)
448        .bind(Chord::character('p').command().shift(), Action::Toggle)
449        .bind(Chord::named(Named::Backspace), Action::Back)
450}
451
452type OnEvent<'a, Id, Message> = Rc<dyn Fn(Event<Id>) -> Message + 'a>;
453
454/// A command palette builder. Convert it into an [`Element`] to render.
455pub struct CommandPalette<'a, Id, Message> {
456    state: &'a State<Id>,
457    base: Element<'a, Message>,
458    title: String,
459    placeholder: String,
460    empty: Option<String>,
461    loading: bool,
462    max_height: f32,
463    size: dialog::Size,
464    keymap: Keymap<Action>,
465    command_keymap: Keymap<command::Action>,
466    dialog_keymap: Keymap<dialog::Action>,
467    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
468}
469
470impl<Id: std::fmt::Debug, Message> std::fmt::Debug for CommandPalette<'_, Id, Message> {
471    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472        f.debug_struct("CommandPalette")
473            .field("state", self.state)
474            .field("title", &self.title)
475            .field("placeholder", &self.placeholder)
476            .field("loading", &self.loading)
477            .field("max_height", &self.max_height)
478            .field("size", &self.size)
479            .finish_non_exhaustive()
480    }
481}
482
483/// Wraps `base`, the app the palette opens over. Without
484/// [`on_event`](CommandPalette::on_event) the list renders disabled.
485pub fn command_palette<'a, Id, Message>(
486    state: &'a State<Id>,
487    base: impl Into<Element<'a, Message>>,
488) -> CommandPalette<'a, Id, Message> {
489    CommandPalette {
490        state,
491        base: base.into(),
492        title: DEFAULT_TITLE.to_owned(),
493        placeholder: "Type a command or search...".to_owned(),
494        empty: None,
495        loading: false,
496        max_height: 400.0,
497        size: dialog::Size::Md,
498        keymap: default_keymap(),
499        command_keymap: command::default_keymap(),
500        dialog_keymap: dialog::default_keymap(),
501        on_event: None,
502    }
503}
504
505impl<'a, Id, Message> CommandPalette<'a, Id, Message> {
506    /// The dialog title on the first page. Nested pages show their own.
507    pub fn title(mut self, title: impl Into<String>) -> Self {
508        self.title = title.into();
509        self
510    }
511
512    /// The search field's placeholder, unless the page sets its own.
513    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
514        self.placeholder = placeholder.into();
515        self
516    }
517
518    /// Text shown when nothing matches the query.
519    pub fn empty(mut self, empty: impl Into<String>) -> Self {
520        self.empty = Some(empty.into());
521        self
522    }
523
524    /// Shows that a background search is still running.
525    pub fn loading(mut self, loading: bool) -> Self {
526        self.loading = loading;
527        self
528    }
529
530    /// Caps the list's height in pixels. It shrinks around a few results.
531    pub fn max_height(mut self, max_height: f32) -> Self {
532        self.max_height = max_height;
533        self
534    }
535
536    /// The dialog's maximum width. Defaults to [`dialog::Size::Md`].
537    pub fn size(mut self, size: dialog::Size) -> Self {
538        self.size = size;
539        self
540    }
541
542    /// Replaces the [`default_keymap`]: the toggle chords and Back.
543    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
544        self.keymap = keymap;
545        self
546    }
547
548    /// Replaces the [command list keys](command::default_keymap).
549    pub fn command_keymap(mut self, keymap: Keymap<command::Action>) -> Self {
550        self.command_keymap = keymap;
551        self
552    }
553
554    /// Replaces the [dialog keys](dialog::default_keymap).
555    pub fn dialog_keymap(mut self, keymap: Keymap<dialog::Action>) -> Self {
556        self.dialog_keymap = keymap;
557        self
558    }
559
560    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
561        self.on_event = Some(Box::new(on_event));
562        self
563    }
564}
565
566impl<'a, Id, Message> From<CommandPalette<'a, Id, Message>> for Element<'a, Message>
567where
568    Id: Clone + PartialEq + 'a,
569    Message: Clone + 'a,
570{
571    fn from(palette: CommandPalette<'a, Id, Message>) -> Self {
572        let CommandPalette {
573            state,
574            base,
575            title,
576            placeholder,
577            empty,
578            loading,
579            max_height,
580            size,
581            keymap,
582            command_keymap,
583            dialog_keymap,
584            on_event,
585        } = palette;
586        let on_event: Option<OnEvent<'a, Id, Message>> = on_event.map(Rc::from);
587        let page = state.page();
588        let title = page.map_or(title, |page| page.title.clone());
589        let placeholder = page
590            .and_then(|page| page.placeholder.clone())
591            .unwrap_or(placeholder);
592        let toggles: Vec<Chord> = keymap
593            .chords(&Action::Toggle)
594            .into_iter()
595            .cloned()
596            .collect();
597
598        let mut list = command(&state.command)
599            .placeholder(placeholder)
600            .loading(loading)
601            .max_height(max_height)
602            .id(SEARCH_ID)
603            .keymap(command_keymap);
604        if let Some(empty) = empty {
605            list = list.empty(empty);
606        }
607
608        let mut palette = dialog(base)
609            .open(state.open)
610            .title(title)
611            .size(size)
612            .close_button(false)
613            .id(DIALOG_ID)
614            .keymap(dialog_keymap)
615            .pass_through(toggles);
616        let Some(on_event) = on_event else {
617            return palette.body(list).into();
618        };
619
620        if !state.stack.is_empty() {
621            palette = palette.action(
622                button("Back")
623                    .icon(crate::lucide!(ChevronLeft))
624                    .variant(Variant::Ghost)
625                    .size(button::Size::Sm)
626                    .on_press(on_event(Event::Back)),
627            );
628        }
629        let on_list = on_event.clone();
630        let on_key = on_event.clone();
631        let body = anchored(list.on_event(move |event| on_list(Event::Command(event))))
632            .dismiss_keys([])
633            .on_key(move |key| state.key_event(&keymap, key).map(&*on_key));
634        palette.body(body).on_dismiss(on_event(Event::Close)).into()
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use crate::navigation::command::item;
642    use iced::keyboard::{Key, Modifiers};
643
644    fn state() -> State<&'static str> {
645        State::new([
646            group(
647                "Files",
648                [item("new", "New file"), item("open", "Open file")],
649            ),
650            group(
651                "Preferences",
652                [
653                    item("theme", "Change theme..."),
654                    item("settings", "Settings"),
655                ],
656            ),
657        ])
658        .with_page(
659            page(
660                "theme",
661                "Theme",
662                [group("", [item("light", "Light"), item("dark", "Dark")])],
663            )
664            .placeholder("Pick a theme..."),
665        )
666    }
667
668    fn opened() -> State<&'static str> {
669        let mut state = state();
670        let _ = state.update(Event::Open);
671        state
672    }
673
674    fn labels(state: &State<&'static str>) -> Vec<(String, String)> {
675        state
676            .command()
677            .results()
678            .map(|(group, item)| (group.to_owned(), item.label.clone()))
679            .collect()
680    }
681
682    fn run(state: &mut State<&'static str>, id: &'static str) -> Option<Output<&'static str>> {
683        let position = state
684            .command()
685            .results()
686            .position(|(_, item)| item.id == id)
687            .expect("the item is listed");
688        state.update(Event::Command(command::Event::Activate(position)))
689    }
690
691    #[test]
692    fn starts_closed_on_the_first_page_with_nothing_recent() {
693        let state = state();
694        assert!(!state.is_open());
695        assert_eq!(state.depth(), 0);
696        assert!(state.page().is_none() && state.page_id().is_none());
697        assert!(state.recent().is_empty());
698        assert_eq!(state.command().len(), 4);
699        assert_eq!(state.query(), "");
700    }
701
702    #[test]
703    fn open_close_and_toggle() {
704        let mut state = state();
705        assert!(state.update(Event::Open).is_none());
706        assert!(state.is_open());
707        let _ = state.update(Event::Close);
708        assert!(!state.is_open());
709        let _ = state.update(Event::Toggle);
710        assert!(state.is_open());
711        let _ = state.update(Event::Toggle);
712        assert!(!state.is_open());
713    }
714
715    #[test]
716    fn running_a_command_closes_and_remembers_it() {
717        let mut state = opened();
718        assert!(matches!(
719            run(&mut state, "settings"),
720            Some(Output::Activated("settings"))
721        ));
722        assert!(!state.is_open());
723        assert_eq!(state.recent(), ["settings"]);
724
725        let _ = state.update(Event::Open);
726        let first = labels(&state).into_iter().next();
727        assert_eq!(first, Some(("Recent".into(), "Settings".into())));
728    }
729
730    #[test]
731    fn recent_commands_are_newest_first_unique_and_capped() {
732        let mut state = state().with_recent_limit(2);
733        for id in ["new", "open", "new", "settings"] {
734            let _ = state.update(Event::Open);
735            let _ = run(&mut state, id);
736        }
737        assert_eq!(state.recent(), ["settings", "new"]);
738
739        let off = self::state().with_recent_limit(0).with_recent(["new"]);
740        assert!(off.recent().is_empty());
741    }
742
743    #[test]
744    fn recent_items_from_nested_pages_are_found() {
745        let state = state().with_recent(["dark", "missing"]);
746        let recent: Vec<_> = labels(&state)
747            .into_iter()
748            .filter(|(group, _)| group == RECENT_LABEL)
749            .map(|(_, label)| label)
750            .collect();
751        assert_eq!(recent, ["Dark"], "unknown ids are skipped");
752    }
753
754    #[test]
755    fn typing_hides_the_recent_group_and_clearing_brings_it_back() {
756        let mut state = state().with_recent(["open"]);
757        let _ = state.update(Event::Open);
758        let output = state.update(Event::Command(command::Event::Input("fi".into())));
759        assert!(matches!(output, Some(Output::Search(query)) if query == "fi"));
760        assert!(
761            labels(&state)
762                .iter()
763                .all(|(group, _)| group != RECENT_LABEL)
764        );
765        assert_eq!(state.query(), "fi");
766
767        let output = state.update(Event::Command(command::Event::Input("fil".into())));
768        assert!(matches!(output, Some(Output::Search(query)) if query == "fil"));
769
770        let output = state.update(Event::Command(command::Event::Input(String::new())));
771        assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
772        assert_eq!(labels(&state)[0].0, RECENT_LABEL);
773    }
774
775    #[test]
776    fn a_page_item_opens_its_page_instead_of_running() {
777        let mut state = opened();
778        assert!(run(&mut state, "theme").is_none());
779        assert!(state.is_open());
780        assert_eq!(state.depth(), 1);
781        assert_eq!(state.page_id(), Some(&"theme"));
782        assert_eq!(state.page().map(|page| page.title.as_str()), Some("Theme"));
783        let items: Vec<_> = labels(&state).into_iter().map(|(_, label)| label).collect();
784        assert_eq!(items, ["Light", "Dark"]);
785
786        assert!(matches!(
787            run(&mut state, "dark"),
788            Some(Output::Activated("dark"))
789        ));
790        assert!(!state.is_open());
791        assert_eq!(state.recent(), ["dark"], "the page itself is not recent");
792    }
793
794    #[test]
795    fn back_returns_to_the_page_before() {
796        let mut state = opened();
797        let _ = run(&mut state, "theme");
798        assert!(state.update(Event::Back).is_none());
799        assert_eq!(state.depth(), 0);
800        assert_eq!(state.command().len(), 4);
801        assert!(state.update(Event::Back).is_none(), "nothing to go back to");
802        assert!(state.is_open());
803    }
804
805    #[test]
806    fn going_back_with_a_query_clears_it() {
807        let mut state = opened();
808        let _ = run(&mut state, "theme");
809        let _ = state.update(Event::Command(command::Event::Input("da".into())));
810        let output = state.update(Event::Back);
811        assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
812        assert_eq!(state.query(), "");
813    }
814
815    #[test]
816    fn escape_on_an_empty_query_goes_back_then_closes() {
817        let mut state = opened();
818        let _ = run(&mut state, "theme");
819        let _ = state.update(Event::Command(command::Event::Close));
820        assert_eq!(state.depth(), 0);
821        assert!(state.is_open());
822        let _ = state.update(Event::Command(command::Event::Close));
823        assert!(!state.is_open());
824    }
825
826    #[test]
827    fn opening_again_starts_on_the_first_page() {
828        let mut state = opened();
829        let _ = run(&mut state, "theme");
830        let _ = state.update(Event::Command(command::Event::Input("li".into())));
831        let _ = state.update(Event::Close);
832        let output = state.update(Event::Open);
833        assert!(matches!(output, Some(Output::Search(query)) if query.is_empty()));
834        assert_eq!(state.depth(), 0);
835        assert_eq!(state.query(), "");
836    }
837
838    #[test]
839    fn focus_survives_a_page_change() {
840        let mut state = opened();
841        let _ = state.update(Event::Command(command::Event::Focus(true)));
842        let _ = run(&mut state, "theme");
843        assert!(state.command().is_focused());
844        assert!(state.command().shows_highlight());
845    }
846
847    #[test]
848    fn set_groups_replaces_the_first_page() {
849        let mut state = opened();
850        state.set_groups([group("Only", [item("one", "One")])]);
851        assert_eq!(labels(&state), [("Only".into(), "One".into())]);
852
853        let _ = state.update(Event::Open);
854        state.set_groups([group("Only", [item("theme", "Change theme...")])]);
855        let _ = run(&mut state, "theme");
856        state.set_groups([group("Other", [item("two", "Two")])]);
857        assert_eq!(state.depth(), 1, "a nested page stays on show");
858    }
859
860    #[test]
861    fn channel_events_pass_through() {
862        let mut state = opened();
863        let (sender, _receiver) = iced::futures::channel::mpsc::channel(1);
864        let output = state.update(Event::Command(command::Event::Ready(sender)));
865        assert!(matches!(output, Some(Output::Ready(_))));
866
867        let _ = state.update(Event::Command(command::Event::Input("xyz".into())));
868        let _ = state.update(Event::Command(command::Event::Received(vec![
869            command::results("xyz", "Files", [item("remote", "xyz.txt")]),
870        ])));
871        assert!(labels(&state).contains(&("Files".into(), "xyz.txt".into())));
872    }
873
874    fn key(key: Key, modifiers: Modifiers) -> keys::Event {
875        keys::Event { key, modifiers }
876    }
877
878    #[test]
879    fn toggle_chords_work_anywhere_and_back_only_on_a_nested_empty_page() {
880        let keymap = default_keymap();
881        let mut state = state();
882        let ctrl_k = key(Key::Character("k".into()), Modifiers::COMMAND);
883        let ctrl_shift_p = key(
884            Key::Character("P".into()),
885            Modifiers::COMMAND | Modifiers::SHIFT,
886        );
887        let backspace = key(Key::Named(Named::Backspace), Modifiers::empty());
888        assert!(matches!(
889            state.key_event(&keymap, &ctrl_k),
890            Some(Event::Toggle)
891        ));
892        assert!(matches!(
893            state.key_event(&keymap, &ctrl_shift_p),
894            Some(Event::Toggle)
895        ));
896        assert!(state.key_event(&keymap, &backspace).is_none());
897
898        let _ = state.update(Event::Open);
899        assert!(state.key_event(&keymap, &backspace).is_none(), "first page");
900        let _ = run(&mut state, "theme");
901        assert!(matches!(
902            state.key_event(&keymap, &backspace),
903            Some(Event::Back)
904        ));
905        let _ = state.update(Event::Command(command::Event::Input("d".into())));
906        assert!(
907            state.key_event(&keymap, &backspace).is_none(),
908            "Backspace deletes text"
909        );
910    }
911
912    #[test]
913    fn default_keymap_covers_every_action() {
914        let keymap = default_keymap();
915        assert_eq!(keymap.chords(&Action::Toggle).len(), 2);
916        assert_eq!(
917            keymap.chords(&Action::Back),
918            [&Chord::named(Named::Backspace)]
919        );
920        assert_eq!(Keymap::<Action>::defaults(), keymap);
921        for &action in <Action as keys::Action>::ALL {
922            assert!(!keys::Action::name(action).is_empty());
923            assert!(keys::Action::description(action).ends_with('.'));
924        }
925    }
926
927    #[test]
928    fn keymap_overrides_and_unbinding() {
929        let keymap = default_keymap()
930            .unbind_action(&Action::Back)
931            .bind(Chord::named(Named::F1), Action::Toggle);
932        let mut state = opened();
933        let _ = run(&mut state, "theme");
934        let backspace = key(Key::Named(Named::Backspace), Modifiers::empty());
935        let f1 = key(Key::Named(Named::F1), Modifiers::empty());
936        assert!(state.key_event(&keymap, &backspace).is_none());
937        assert!(matches!(state.key_event(&keymap, &f1), Some(Event::Toggle)));
938    }
939
940    #[test]
941    fn builder_defaults_and_overrides() {
942        let state = state();
943        let palette: CommandPalette<'_, &str, ()> = command_palette(&state, widget::text("App"));
944        assert_eq!(palette.title, DEFAULT_TITLE);
945        assert!(palette.empty.is_none());
946        assert!(!palette.loading);
947        assert_eq!(palette.size, dialog::Size::Md);
948        assert_eq!(palette.keymap, default_keymap());
949        assert_eq!(palette.command_keymap, command::default_keymap());
950        assert_eq!(palette.dialog_keymap, dialog::default_keymap());
951        assert!(palette.on_event.is_none());
952
953        let palette: CommandPalette<'_, &str, ()> = command_palette(&state, widget::text("App"))
954            .title("Go to")
955            .placeholder("Search...")
956            .empty("Nothing here.")
957            .loading(true)
958            .max_height(200.0)
959            .size(dialog::Size::Lg)
960            .keymap(Keymap::new())
961            .command_keymap(Keymap::new())
962            .dialog_keymap(Keymap::new())
963            .on_event(|_| ());
964        assert_eq!(palette.title, "Go to");
965        assert_eq!(palette.placeholder, "Search...");
966        assert_eq!(palette.empty.as_deref(), Some("Nothing here."));
967        assert!(palette.loading);
968        assert_eq!(palette.max_height, 200.0);
969        assert_eq!(palette.size, dialog::Size::Lg);
970        assert!(palette.keymap.is_empty() && palette.command_keymap.is_empty());
971        assert!(palette.dialog_keymap.is_empty() && palette.on_event.is_some());
972    }
973}