Skip to main content

qframe/widgets/
command_palette.rs

1//! The command palette: find and run any command by typing a few letters of its name.
2
3use crate::event::{Event, MouseButton, MouseKind};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::keymap::{Key, KeyChord, Scope};
6use crate::style::CellStyle;
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11use super::cells;
12use super::editor::Editor;
13use super::filter;
14use super::layer::{self, Backdrop, BarDrag, PointerGate, SurfacePosition};
15use super::rows::WHEEL_ROWS;
16use super::scrollbar::{self, ScrollMetrics};
17
18/// Width of the palette when none is set, in cells.
19const DEFAULT_WIDTH: u16 = 72;
20
21/// Rows of commands shown before the list scrolls, at least, whatever the screen.
22const MAX_ROWS: u16 = 10;
23
24/// Global actions that make no sense as commands: moving focus inside the palette, or opening
25/// the palette itself.
26const HIDDEN_ACTIONS: [&str; 3] = ["focus-next", "focus-prev", "palette"];
27
28/// Builds a message from the id of a command.
29type IdMessage<Msg> = Box<dyn Fn(&str) -> Msg>;
30
31/// One command of a [`CommandPalette`].
32pub struct PaletteCommand<Msg> {
33    id: String,
34    label: String,
35    chord: Option<String>,
36    message: Msg,
37}
38
39impl<Msg> PaletteCommand<Msg> {
40    /// A command named `label` that sends `message`; `id` identifies it for
41    /// [`CommandPalette::recent`] and [`CommandPalette::on_run`].
42    #[must_use]
43    pub fn new(id: impl Into<String>, label: impl Into<String>, message: Msg) -> Self {
44        Self { id: id.into(), label: label.into(), chord: None, message }
45    }
46
47    /// The key that runs the command outside the palette, shown on the right, e.g. `"ctrl r"`.
48    #[must_use]
49    pub fn chord(mut self, label: impl Into<String>) -> Self {
50        self.chord = Some(label.into());
51        self
52    }
53}
54
55/// What running an entry does.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum Target {
58    /// Sends the message of the application command at this index.
59    Command(usize),
60    /// Runs the keymap action at this index of the entries built from the keymap.
61    Action(usize),
62}
63
64/// A row of the palette list.
65#[derive(Debug, Clone, PartialEq, Eq)]
66enum Row {
67    Header(String),
68    Entry { target: Target, positions: Vec<usize> },
69}
70
71/// A keymap action offered as a command.
72#[derive(Debug, Clone, PartialEq, Eq)]
73struct ActionEntry {
74    scope: Scope,
75    action: String,
76    label: String,
77    chord: Option<String>,
78}
79
80#[derive(Debug, Default)]
81struct PaletteMemory {
82    editor: Editor,
83    /// The highlighted entry, counted among entries only.
84    highlight: usize,
85    offset: usize,
86    visible: usize,
87    list: Rect,
88    /// The pointer moves the highlight only once it moves after the palette opened.
89    pointer: PointerGate,
90    bar: BarDrag,
91}
92
93/// A layer with a filter field over a list of commands, for running anything from the
94/// keyboard.
95///
96/// Typing filters the commands with fuzzy matching, best matches first, matched characters in
97/// the match colour. ↑/↓ (or Ctrl+P/Ctrl+N), PgUp/PgDn move the highlight, Enter runs the
98/// highlighted command. The mouse does the same: moving the pointer over a row moves the one
99/// highlight (a pointer resting where the palette opened waits until it moves), a click runs
100/// the clicked command and the wheel scrolls. It is dismissable by default: Esc, the close
101/// mark `×` at the top right and a click on the dimmed screen close it;
102/// [`dismissable(false)`](CommandPalette::dismissable) turns all three off. Running sends the
103/// close message first, then the command's message. Only visible rows are drawn, so thousands of
104/// commands stay fast. Add it to the view while it should be shown; it opens as a modal layer
105/// in the upper part of the screen. Applications usually open it from the global `palette`
106/// action, bound to Ctrl+P.
107///
108/// With no options it lists the given commands. `keymap` adds the keymap's actions with
109/// their keys; `recent` lists the given command ids first under "Recent" while nothing is
110/// typed; `on_run` reports which command ran, e.g. to remember recent ones.
111///
112/// Style keys: `modal` (with its `pillar` down the left edge), `close-mark`, `layer-backdrop`,
113/// `layer-filter`, `layer-filter-mark`, `layer-filter-placeholder`, `layer-filter-cursor`,
114/// `layer-match`, `palette-item` with `hover` (`bg`, `fg`, `pillar`), `palette-chord`,
115/// `palette-header`, `layer-hint-key`, `layer-hint-label`. Text: `quvyta.palette.*`,
116/// `quvyta.layer.*`.
117pub struct CommandPalette<Msg> {
118    commands: Vec<PaletteCommand<Msg>>,
119    keymap: bool,
120    recent: Vec<String>,
121    placeholder: Option<String>,
122    width: u16,
123    max_rows: Option<u16>,
124    dismissable: bool,
125    on_close: Msg,
126    on_run: Option<IdMessage<Msg>>,
127}
128
129impl<Msg: Clone + 'static> CommandPalette<Msg> {
130    /// A palette of `commands`; dismissing it (Esc, the close mark, a click outside) and running a
131    /// command send `on_close`.
132    #[must_use]
133    pub fn new(commands: impl IntoIterator<Item = PaletteCommand<Msg>>, on_close: Msg) -> Self {
134        Self {
135            commands: commands.into_iter().collect(),
136            keymap: false,
137            recent: Vec::new(),
138            placeholder: None,
139            width: DEFAULT_WIDTH,
140            max_rows: None,
141            dismissable: true,
142            on_close,
143            on_run: None,
144        }
145    }
146
147    /// Also lists the keymap's actions (except moving focus and the palette itself), labelled
148    /// from the language files and showing their first key. Running one runs the action as if
149    /// its key had been pressed. Their ids are `action:<name>`.
150    #[must_use]
151    pub fn keymap(mut self, include: bool) -> Self {
152        self.keymap = include;
153        self
154    }
155
156    /// Ids of recently run commands, most recent first, listed on top while nothing is typed.
157    #[must_use]
158    pub fn recent(mut self, ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
159        self.recent = ids.into_iter().map(Into::into).collect();
160        self
161    }
162
163    /// The faint text of the empty filter; `quvyta.palette.placeholder` by default.
164    #[must_use]
165    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
166        self.placeholder = Some(text.into());
167        self
168    }
169
170    /// Width in cells, padding included; 72 by default. Narrow screens shrink it.
171    #[must_use]
172    pub fn width(mut self, cells: u16) -> Self {
173        self.width = cells;
174        self
175    }
176
177    /// Rows of commands shown at most before the list scrolls. Without it the list takes up to
178    /// half the screen's rows, and never fewer than ten, so a tall screen shows more at once.
179    #[must_use]
180    pub fn max_rows(mut self, rows: u16) -> Self {
181        self.max_rows = Some(rows.max(1));
182        self
183    }
184
185    /// Whether Esc, the close mark and a click on the dimmed screen close the palette; `true` by
186    /// default. With `false` none of them works and the mark is not drawn; running a command
187    /// still sends the close message.
188    #[must_use]
189    pub fn dismissable(mut self, dismissable: bool) -> Self {
190        self.dismissable = dismissable;
191        self
192    }
193
194    /// The message sent with the id of every command that runs, after the command's own.
195    #[must_use]
196    pub fn on_run(mut self, message: impl Fn(&str) -> Msg + 'static) -> Self {
197        self.on_run = Some(Box::new(message));
198        self
199    }
200
201    fn actions(&self, env: &crate::env::Env) -> Vec<ActionEntry> {
202        if !self.keymap {
203            return Vec::new();
204        }
205        env.keymap()
206            .iter()
207            .filter(|(scope, action, _)| !(*scope == Scope::Global && HIDDEN_ACTIONS.contains(action)))
208            .map(|(scope, action, chords)| ActionEntry {
209                scope,
210                action: action.to_owned(),
211                label: env.i18n().translate(&scope.label_key(action), &[]),
212                chord: chords.first().map(KeyChord::label),
213            })
214            .collect()
215    }
216
217    fn id(&self, target: Target, actions: &[ActionEntry]) -> String {
218        match target {
219            Target::Command(index) => self.commands[index].id.clone(),
220            Target::Action(index) => format!("action:{}", actions[index].action),
221        }
222    }
223
224    fn label<'a>(&'a self, target: Target, actions: &'a [ActionEntry]) -> &'a str {
225        match target {
226            Target::Command(index) => &self.commands[index].label,
227            Target::Action(index) => &actions[index].label,
228        }
229    }
230
231    /// The rows for `query`: best matches first, or recent commands and then all in order while
232    /// the query is empty.
233    fn rows(&self, env: &crate::env::Env, actions: &[ActionEntry], query: &str) -> Vec<Row> {
234        let i18n = env.i18n();
235        let targets: Vec<Target> =
236            (0..self.commands.len()).map(Target::Command).chain((0..actions.len()).map(Target::Action)).collect();
237        if !query.trim().is_empty() {
238            let mut matched: Vec<(i32, usize, Row)> = targets
239                .iter()
240                .enumerate()
241                .filter_map(|(order, target)| {
242                    let found = filter::fuzzy(query, self.label(*target, actions))?;
243                    Some((found.score, order, Row::Entry { target: *target, positions: found.positions }))
244                })
245                .collect();
246            matched.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
247            return matched.into_iter().map(|(_, _, row)| row).collect();
248        }
249        let entry = |target: Target| Row::Entry { target, positions: Vec::new() };
250        let recent: Vec<Target> = self
251            .recent
252            .iter()
253            .filter_map(|id| targets.iter().find(|target| self.id(**target, actions) == *id).copied())
254            .collect();
255        if recent.is_empty() {
256            return targets.into_iter().map(entry).collect();
257        }
258        let mut rows = vec![Row::Header(i18n.translate("quvyta.palette.recent", &[]))];
259        rows.extend(recent.iter().copied().map(entry));
260        rows.push(Row::Header(i18n.translate("quvyta.palette.all", &[])));
261        rows.extend(targets.into_iter().filter(|target| !recent.contains(target)).map(entry));
262        rows
263    }
264
265    /// The row index of entry number `entry`.
266    fn row_of_entry(rows: &[Row], entry: usize) -> Option<usize> {
267        rows.iter().enumerate().filter(|(_, row)| matches!(row, Row::Entry { .. })).nth(entry).map(|(index, _)| index)
268    }
269
270    fn entries(rows: &[Row]) -> usize {
271        rows.iter().filter(|row| matches!(row, Row::Entry { .. })).count()
272    }
273
274    fn run(&self, cx: &mut EventCx<'_, Msg>, target: Target, actions: &[ActionEntry]) {
275        cx.emit(self.on_close.clone());
276        match target {
277            Target::Command(index) => cx.emit(self.commands[index].message.clone()),
278            Target::Action(index) => cx.run_action(actions[index].scope, actions[index].action.clone()),
279        }
280        if let Some(on_run) = &self.on_run {
281            let id = self.id(target, actions);
282            cx.emit(on_run(&id));
283        }
284    }
285
286    /// Moves the highlight to entry `entry` and scrolls it into view.
287    fn highlight(cx: &mut EventCx<'_, Msg>, rows: &[Row], entry: usize) {
288        let count = Self::entries(rows);
289        if count == 0 {
290            return;
291        }
292        let entry = entry.min(count - 1);
293        let row = Self::row_of_entry(rows, entry).unwrap_or(0);
294        let memory = cx.memory::<PaletteMemory>();
295        memory.highlight = entry;
296        let visible = memory.visible.max(1);
297        // Keep a header right above the first entry of its group in view too.
298        let top = if row > 0 && matches!(rows[row - 1], Row::Header(_)) { row - 1 } else { row };
299        if top < memory.offset {
300            memory.offset = top;
301        } else if row >= memory.offset + visible {
302            memory.offset = row + 1 - visible;
303        }
304    }
305}
306
307impl<Msg: Clone + 'static> Widget<Msg> for CommandPalette<Msg> {
308    fn measure(&self, _cx: &mut MeasureCx<'_>, _available: Size) -> Size {
309        Size::default()
310    }
311
312    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
313        cx.request_overlay(area);
314    }
315
316    fn paint_overlay(&self, cx: &mut PaintCx<'_>, _anchor: Rect) {
317        let screen = cx.clip();
318        let actions = self.actions(cx.env());
319        let query = cx.memory::<PaletteMemory>().editor.text().to_owned();
320        let rows = self.rows(cx.env(), &actions, &query);
321        let padding = layer::padding(cx, "modal", self.dismissable);
322        // The filter and the hint line, each with a blank row.
323        let chrome = padding.vertical().saturating_add(4);
324        let most = self.max_rows.unwrap_or_else(|| MAX_ROWS.max(screen.height / 2));
325        let room = screen.height.saturating_sub(chrome.saturating_add(2)).clamp(1, most);
326        let visible = clamp_u16(i32::try_from(rows.len()).unwrap_or(i32::MAX)).clamp(1, room);
327        let width = self.width.min(screen.width.saturating_sub(2));
328        let look = layer::Look { style: "modal", variant: None, dismissable: self.dismissable };
329        let surface = layer::open(cx, Size::new(width, chrome.saturating_add(visible)), SurfacePosition::Top, look);
330        let inner = surface.inner;
331        let list =
332            Rect::new(inner.x - i32::from(padding.left), inner.y + 2, inner.width + padding.horizontal(), visible);
333        // The scrollbar sits in the list's right padding, one cell in from the surface's edge.
334        let bar = Rect::new(list.right() - i32::from(padding.right.max(2)) + 1, list.y, 1, list.height);
335        let pointer = cx.pointer_anywhere();
336        let (metrics, highlight, editor) = {
337            let memory = cx.memory::<PaletteMemory>();
338            if surface.fresh {
339                *memory = PaletteMemory { pointer: PointerGate::new(pointer), ..PaletteMemory::default() };
340            }
341            memory.highlight = memory.highlight.min(Self::entries(&rows).saturating_sub(1));
342            let total = rows.len();
343            memory.offset = memory.offset.min(total.saturating_sub(usize::from(visible)));
344            memory.visible = usize::from(visible);
345            memory.list = list;
346            // The pointer carries the one highlight when it moves onto an entry.
347            let moved = memory.pointer.moved(pointer);
348            // The scrollbar column is not a row: dragging it must not move the highlight.
349            let rows_end = if total > usize::from(visible) { bar.x } else { list.right() };
350            let hovered = pointer
351                .filter(|(x, y)| moved && list.contains(*x, *y) && *x < rows_end)
352                .and_then(|(_, y)| usize::try_from(y - list.y).ok())
353                .map(|row| memory.offset + row)
354                .filter(|row| matches!(rows.get(*row), Some(Row::Entry { .. })))
355                .map(|row| rows[..row].iter().filter(|r| matches!(r, Row::Entry { .. })).count());
356            if let Some(entry) = hovered {
357                memory.highlight = entry;
358            }
359            (
360                ScrollMetrics { total, visible: usize::from(visible), offset: memory.offset },
361                memory.highlight,
362                memory.editor.clone(),
363            )
364        };
365        let highlighted_row = Self::row_of_entry(&rows, highlight);
366        let slide = cx.env().slide();
367        cx.with_clip(surface.shown, |cx| {
368            let placeholder = self
369                .placeholder
370                .clone()
371                .unwrap_or_else(|| cx.env().i18n().translate("quvyta.palette.placeholder", &[]));
372            filter::paint(cx, Rect::new(inner.x, inner.y, inner.width, 1), &editor, &placeholder);
373            let content_width =
374                if metrics.overflows() { list.width.saturating_sub(padding.right.max(2)) } else { list.width };
375            if rows.is_empty() {
376                let empty = cx.env().i18n().translate("quvyta.palette.empty", &[]);
377                let style = cx.style("palette-header", None, &[]).text();
378                cx.text(inner.x, list.y, &empty, style, inner.width);
379            }
380            for (line, row) in rows.iter().enumerate().skip(metrics.offset).take(usize::from(visible)) {
381                let y = list.y + i32::try_from(line - metrics.offset).unwrap_or(0);
382                let rect = Rect::new(list.x, y, content_width, 1);
383                match row {
384                    Row::Header(title) => {
385                        let style = cx.style("palette-header", None, &[]).text();
386                        cx.text(inner.x, y, title, style, inner.width);
387                    }
388                    Row::Entry { target, positions } => {
389                        // One highlight, moved by the keyboard and by the pointer alike.
390                        let states = if highlighted_row == Some(line) { vec![State::Hover] } else { Vec::new() };
391                        let style = cx.style("palette-item", None, &states);
392                        let text_style = style.text();
393                        if let Some(bg) = text_style.bg {
394                            cx.fill(rect, bg);
395                        }
396                        if let Some(color) = style.color("pillar") {
397                            cx.pillar(rect.x, y, color);
398                        }
399                        let chord = match target {
400                            Target::Command(index) => self.commands[*index].chord.clone(),
401                            Target::Action(index) => actions[*index].chord.clone(),
402                        };
403                        let chord_width = chord.as_deref().map_or(0, text::width);
404                        if let Some(chord) = &chord {
405                            let chord_style = cx.style("palette-chord", None, &states).text();
406                            let x = inner.right() - i32::from(chord_width);
407                            cx.text(x, y, chord, CellStyle { bg: None, ..chord_style }, chord_width);
408                        }
409                        let shift = u16::from(slide && !states.is_empty());
410                        let label_end = inner.right() - i32::from(chord_width) - if chord_width > 0 { 2 } else { 0 };
411                        // The label is cut at the same place resting and raised, one cell short of its
412                        // room, so the sliding label never touches the chord.
413                        let budget = clamp_u16(label_end - inner.x - 1);
414                        let label = self.label(*target, &actions);
415                        filter::paint_matched(cx, inner.x + i32::from(shift), y, label, budget, positions, text_style);
416                    }
417                }
418            }
419            if metrics.overflows() {
420                let active = {
421                    let memory = cx.memory::<PaletteMemory>();
422                    memory.bar.place(Some(bar));
423                    memory.bar
424                }
425                .active(cx.pointer_anywhere());
426                scrollbar::paint(cx, bar, metrics, active, None);
427            } else {
428                cx.memory::<PaletteMemory>().bar.place(None);
429            }
430            let mut hints = Vec::new();
431            if self.dismissable {
432                hints.push(layer::hint(cx, "esc", "close"));
433            }
434            hints.extend([layer::hint(cx, &layer::arrows(cx), "move"), layer::hint(cx, &layer::enter(cx), "run")]);
435            layer::paint_hints(cx, inner.x, inner.bottom() - 1, inner.width, &hints);
436            let count = format!("{} / {}", Self::entries(&rows), self.commands.len() + actions.len());
437            let count_style = cx.style("layer-hint-label", None, &[]).text();
438            let count_width = text::width(&count);
439            if cells::sum([layer::hints_width(&hints), count_width, 3]) <= inner.width {
440                cx.text(inner.right() - i32::from(count_width), inner.bottom() - 1, &count, count_style, count_width);
441            }
442        });
443        layer::finish(cx, &surface);
444    }
445
446    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
447        let actions = self.actions(cx.env());
448        let query = cx.memory::<PaletteMemory>().editor.text().to_owned();
449        let rows = self.rows(cx.env(), &actions, &query);
450        let highlight = cx.memory::<PaletteMemory>().highlight;
451        match layer::backdrop_event(cx, event, self.dismissable, true) {
452            Backdrop::Close => {
453                cx.emit(self.on_close.clone());
454                return true;
455            }
456            Backdrop::Swallowed | Backdrop::Inside => {
457                let Event::Mouse(mouse) = event else {
458                    return true;
459                };
460                let (list, offset, mut bar, visible) = {
461                    let memory = cx.memory::<PaletteMemory>();
462                    (memory.list, memory.offset, memory.bar, memory.visible)
463                };
464                let metrics = ScrollMetrics { total: rows.len(), visible, offset };
465                let dragged = bar.event(cx, mouse, metrics);
466                let memory = cx.memory::<PaletteMemory>();
467                memory.bar = bar;
468                if let Some(offset) = dragged {
469                    memory.offset = offset.min(metrics.max_offset());
470                    return true;
471                }
472                if !list.contains(mouse.x, mouse.y) {
473                    return true;
474                }
475                match mouse.kind {
476                    MouseKind::ScrollUp | MouseKind::ScrollDown => {
477                        let memory = cx.memory::<PaletteMemory>();
478                        memory.offset = if mouse.kind == MouseKind::ScrollUp {
479                            memory.offset.saturating_sub(usize::from(WHEEL_ROWS))
480                        } else {
481                            memory.offset.saturating_add(usize::from(WHEEL_ROWS)).min(metrics.max_offset())
482                        };
483                    }
484                    MouseKind::Down(MouseButton::Left) => {
485                        let line = offset + usize::try_from(mouse.y - list.y).unwrap_or(0);
486                        if let Some(Row::Entry { target, .. }) = rows.get(line) {
487                            self.run(cx, *target, &actions);
488                        }
489                    }
490                    _ => {}
491                }
492                return true;
493            }
494            Backdrop::Ignored => {}
495        }
496        if let Event::Key(key) = event {
497            let page = cx.memory::<PaletteMemory>().visible.max(1);
498            let ctrl = |c: char| key.chord.key == Key::Char(c) && key.chord.mods.ctrl && !key.chord.mods.alt;
499            if key.is_plain(Key::Up) || ctrl('p') {
500                Self::highlight(cx, &rows, highlight.saturating_sub(1));
501                return true;
502            }
503            if key.is_plain(Key::Down) || ctrl('n') {
504                Self::highlight(cx, &rows, highlight + 1);
505                return true;
506            }
507            if key.is_plain(Key::PageUp) {
508                Self::highlight(cx, &rows, highlight.saturating_sub(page));
509                return true;
510            }
511            if key.is_plain(Key::PageDown) {
512                Self::highlight(cx, &rows, highlight + page);
513                return true;
514            }
515            if key.is_plain(Key::Enter) {
516                if let Some(Row::Entry { target, .. }) = Self::row_of_entry(&rows, highlight).map(|row| &rows[row]) {
517                    self.run(cx, *target, &actions);
518                }
519                return true;
520            }
521        }
522        let memory = cx.memory::<PaletteMemory>();
523        let before = memory.editor.text().to_owned();
524        let used = filter::edit(&mut memory.editor, event);
525        if memory.editor.text() != before {
526            memory.highlight = 0;
527            memory.offset = 0;
528        }
529        used
530    }
531
532    fn focusable(&self) -> bool {
533        true
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use std::time::Duration;
540
541    use super::*;
542    use crate::runtime::{App, Command, Harness};
543    use crate::widget::View;
544    use crate::widgets::Text;
545
546    #[derive(Default)]
547    struct Demo {
548        open: bool,
549        log: Vec<String>,
550        recent: Vec<String>,
551        count: usize,
552        firm: bool,
553        rows: Option<u16>,
554    }
555
556    #[derive(Clone)]
557    enum Msg {
558        Open,
559        Close,
560        Run(String),
561        Ran(String),
562    }
563
564    impl App for Demo {
565        type Msg = Msg;
566        fn update(&mut self, msg: Msg) -> Command<Msg> {
567            match msg {
568                Msg::Open => self.open = true,
569                Msg::Close => self.open = false,
570                Msg::Run(name) => self.log.push(name),
571                Msg::Ran(id) => {
572                    self.recent.retain(|r| *r != id);
573                    self.recent.insert(0, id);
574                }
575            }
576            Command::none()
577        }
578        fn view(&self, ui: &mut View<'_, Msg>) {
579            ui.column(|ui| {
580                ui.add(Text::new("Deploys"));
581                if self.open {
582                    let mut commands = vec![
583                        PaletteCommand::new("restart", "Restart container", Msg::Run("restart".into())).chord("ctrl r"),
584                        PaletteCommand::new("logs", "Open logs", Msg::Run("logs".into())),
585                        PaletteCommand::new("deploy", "Deploy to staging", Msg::Run("deploy".into())),
586                    ];
587                    commands.extend((0..self.count).map(|i| {
588                        PaletteCommand::new(
589                            format!("image-{i}"),
590                            format!("Pull image {i}"),
591                            Msg::Run(format!("pull {i}")),
592                        )
593                    }));
594                    let mut palette = CommandPalette::new(commands, Msg::Close)
595                        .dismissable(!self.firm)
596                        .keymap(true)
597                        .recent(self.recent.clone())
598                        .on_run(|id| Msg::Ran(id.to_owned()));
599                    if let Some(rows) = self.rows {
600                        palette = palette.max_rows(rows);
601                    }
602                    ui.add(palette);
603                }
604            });
605        }
606        fn action(&self, name: &str) -> Option<Msg> {
607            match name {
608                "palette" => Some(Msg::Open),
609                "help" => Some(Msg::Run("help".into())),
610                _ => None,
611            }
612        }
613    }
614
615    fn opened(demo: Demo) -> Harness<Demo> {
616        let mut h = Harness::new(demo, 70, 24);
617        h.press("ctrl+p").advance(Duration::from_millis(200));
618        h
619    }
620
621    fn visible_pulls(h: &Harness<Demo>) -> usize {
622        h.screen().lines().filter(|line| line.contains("Pull image")).count()
623    }
624
625    #[test]
626    fn a_tall_screen_shows_more_commands_at_once_and_max_rows_caps_them() {
627        let mut h = Harness::new(Demo { count: 20, ..Demo::default() }, 70, 30);
628        h.press("ctrl+p").advance(Duration::from_millis(200));
629        h.type_text("pull");
630        assert!(visible_pulls(&h) > 10, "half of thirty rows, not ten:\n{}", h.screen());
631        let mut small = Harness::new(Demo { count: 20, ..Demo::default() }, 70, 16);
632        small.press("ctrl+p").advance(Duration::from_millis(200));
633        small.type_text("pull");
634        assert!(visible_pulls(&small) <= 10 && visible_pulls(&small) > 0, "{}", small.screen());
635        let mut capped = Harness::new(Demo { count: 20, rows: Some(5), ..Demo::default() }, 70, 30);
636        capped.press("ctrl+p").advance(Duration::from_millis(200));
637        capped.type_text("pull");
638        assert_eq!(visible_pulls(&capped), 5, "{}", capped.screen());
639    }
640
641    #[test]
642    fn the_hint_line_is_ascii_in_the_ascii_glyph_mode() {
643        let mut h = Harness::new(Demo::default(), 70, 24);
644        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
645        h.press("ctrl+p").advance(Duration::from_millis(200));
646        let screen = h.screen();
647        let hints = screen.lines().find(|line| line.contains("move")).unwrap_or_else(|| panic!("{screen}"));
648        assert!(hints.trim().is_ascii(), "{hints:?}");
649        assert!(hints.contains("^v"), "the arrows from the icon set: {hints:?}");
650    }
651
652    #[test]
653    fn lists_commands_and_keymap_actions_with_chords_right_aligned() {
654        let h = opened(Demo::default());
655        let screen = h.screen();
656        for expected in ["Type a command", "Restart container", "ctrl r", "Open logs", "quit", "ctrl q", "keys"] {
657            assert!(screen.contains(expected), "{expected}:\n{screen}");
658        }
659        assert!(!screen.contains("next"), "moving focus is not a command");
660        let line = screen.lines().find(|line| line.contains("Restart container")).unwrap_or_default();
661        assert!(line.starts_with("       ▌ ") || line.contains("▌"), "the first entry is highlighted: {line}");
662    }
663
664    #[test]
665    fn filters_fuzzily_and_runs_with_enter() {
666        let mut h = opened(Demo::default());
667        h.type_text("dstg");
668        let screen = h.screen();
669        assert!(screen.contains("Deploy to staging") && !screen.contains("Open logs"), "{screen}");
670        let (x, y) = h.find("Deploy to").expect("row");
671        assert_eq!(h.fg(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0)), h.env().theme().color("accent"));
672        h.press("enter");
673        assert_eq!(h.app().log, ["deploy"]);
674        assert!(!h.app().open);
675        assert_eq!(h.app().recent, ["deploy"]);
676    }
677
678    #[test]
679    fn arrows_move_and_keymap_actions_run_like_their_keys() {
680        let mut h = opened(Demo::default());
681        h.type_text("keys").press("enter");
682        assert_eq!(h.app().log, ["help"], "the help action ran through App::action");
683        let mut h = opened(Demo::default());
684        h.press("down").press("enter");
685        assert_eq!(h.app().log, ["logs"]);
686        let mut h = opened(Demo::default());
687        h.type_text("zzzz");
688        assert!(h.screen().contains("No matching commands"));
689        h.press("enter").press("esc");
690        assert!(!h.app().open && h.app().log.is_empty());
691    }
692
693    #[test]
694    fn recent_commands_come_first_and_clicks_run() {
695        let mut h = opened(Demo { recent: vec!["logs".into()], ..Demo::default() });
696        let screen = h.screen();
697        assert!(screen.find("Recent") < screen.find("Open logs"), "{screen}");
698        assert!(screen.find("Open logs") < screen.find("All commands"));
699        h.click_text("Deploy to staging");
700        assert_eq!(h.app().log, ["deploy"]);
701        let mut h = opened(Demo::default());
702        h.click(1, 22);
703        assert!(!h.app().open, "a click outside closes the palette");
704    }
705
706    #[test]
707    fn thousands_of_commands_scroll_with_the_highlight() {
708        let mut h = opened(Demo { count: 5000, ..Demo::default() });
709        h.type_text("image 4999");
710        assert!(h.screen().contains("Pull image 4999"), "{}", h.screen());
711        h.press("ctrl+u");
712        for _ in 0..30 {
713            h.press("pgdn");
714        }
715        assert!(h.screen().contains("Pull image"), "{}", h.screen());
716        h.press("enter");
717        assert_eq!(h.app().log.len(), 1);
718    }
719
720    // The palette with the mouse.
721
722    /// The rows raised by the highlight, told by their surface: every row carries the dialog's
723    /// pillar, so the glyph alone says nothing.
724    fn lit(h: &Harness<Demo>) -> Vec<String> {
725        let active = h.env().theme().color("active");
726        h.screen()
727            .lines()
728            .enumerate()
729            .filter(|(y, _)| u16::try_from(*y).is_ok_and(|y| h.bg(10, y) == active))
730            .map(|(_, line)| line.replace('▌', "").split_whitespace().collect::<Vec<_>>().join(" "))
731            .collect()
732    }
733
734    #[test]
735    fn moving_the_pointer_moves_the_one_highlight_and_keys_continue_from_it() {
736        let mut h = opened(Demo::default());
737        assert_eq!(lit(&h), ["Restart container ctrl r"], "{}", h.screen());
738        let (x, y) = h.find("Deploy to staging").expect("row");
739        h.hover(x + 2, y);
740        assert_eq!(lit(&h), ["Deploy to staging"], "only the hovered row is lit:\n{}", h.screen());
741        h.press("up");
742        assert_eq!(lit(&h), ["Open logs"], "the key moves on from the hovered row and the resting pointer waits");
743        h.press("enter");
744        assert_eq!(h.app().log, ["logs"]);
745    }
746
747    #[test]
748    fn a_pointer_resting_where_the_palette_opens_changes_nothing_until_it_moves() {
749        let mut probe = opened(Demo::default());
750        let (x, y) = probe.find("Open logs").expect("row");
751        let mut h = Harness::new(Demo::default(), 70, 24);
752        h.hover(x, y).press("ctrl+p").advance(Duration::from_millis(200));
753        assert_eq!(lit(&h), ["Restart container ctrl r"], "{}", h.screen());
754        h.hover(x + 1, y);
755        assert_eq!(lit(&h), ["Open logs"], "{}", h.screen());
756        probe.press("esc");
757    }
758
759    #[test]
760    fn a_click_runs_the_clicked_row_and_the_wheel_scrolls() {
761        let mut h = opened(Demo { count: 40, ..Demo::default() });
762        assert!(!h.screen().contains("Pull image 12"), "{}", h.screen());
763        let (x, y) = h.find("Open logs").expect("row");
764        for _ in 0..4 {
765            h.mouse(MouseKind::ScrollDown, x, y);
766        }
767        assert!(h.screen().contains("Pull image 12") && !h.screen().contains("Open logs"), "{}", h.screen());
768        h.mouse(MouseKind::ScrollUp, x, y);
769        let (x, y) = h.find("Pull image 9").expect("scrolled row");
770        h.click(x, y);
771        assert_eq!(h.app().log, ["pull 9"]);
772        assert!(!h.app().open);
773    }
774
775    #[test]
776    fn escape_the_close_mark_and_the_dimmed_screen_close_only_while_dismissable() {
777        let mut h = opened(Demo::default());
778        let screen = h.screen();
779        let lines: Vec<&str> = screen.lines().collect();
780        let filter = lines.iter().position(|line| line.contains("Type a command")).unwrap_or_default();
781        assert!(lines[filter - 1].ends_with('×'), "the mark sits on the surface's first row: {screen}");
782        let (x, y) = h.find("×").expect("mark");
783        h.hover(x, y);
784        let (column, row) = (u16::try_from(x).expect("x"), u16::try_from(y).expect("y"));
785        let lit = h.bg(column, row);
786        assert_ne!(lit, h.bg(column - 2, row), "the mark lights up");
787        assert_eq!((h.bg(column - 1, row), h.bg(column + 1, row)), (lit, lit), "all three cells light up");
788        h.click(x, y);
789        assert!(!h.app().open, "the mark closes");
790        let mut h = opened(Demo { firm: true, ..Demo::default() });
791        assert!(!h.screen().contains('×') && !h.screen().contains("esc close"), "{}", h.screen());
792        h.press("esc").click(1, 22);
793        assert!(h.app().open, "neither Esc nor the dimmed screen closes it");
794        h.click_text("Open logs");
795        assert_eq!((h.app().open, h.app().log.as_slice()), (false, &["logs".to_owned()][..]), "running still closes");
796    }
797
798    #[test]
799    fn the_scrollbar_can_be_dragged_without_moving_the_highlight() {
800        let mut h = opened(Demo { count: 40, ..Demo::default() });
801        let (mark_x, _) = h.find("×").expect("close mark");
802        let (_, top) = h.find("Restart container").expect("first row");
803        // The bar sits in the list's right padding, under the close mark's glyph.
804        let x = mark_x;
805        h.mouse(MouseKind::Down(MouseButton::Left), x, top);
806        h.mouse(MouseKind::Drag(MouseButton::Left), x, top + 30);
807        assert!(h.screen().contains("Pull image 39"), "dragged to the end:\n{}", h.screen());
808        h.mouse(MouseKind::Up(MouseButton::Left), x, top + 30);
809        assert!(h.app().open && h.app().log.is_empty(), "nothing ran and the palette stayed");
810        h.press("enter");
811        assert_eq!(h.app().log, ["restart"], "the highlight stayed on the first entry");
812    }
813
814    #[test]
815    fn a_raised_entry_is_cut_at_the_same_place_as_a_resting_one() {
816        let mut h = Harness::new(Demo::default(), 28, 24);
817        h.press("ctrl+p").advance(Duration::from_millis(200));
818        let row = |h: &Harness<Demo>| {
819            let screen = h.screen();
820            let line = screen.lines().find(|line| line.contains("Restart c")).unwrap_or_default().replace('▌', " ");
821            line.split("ctrl").next().unwrap_or_default().trim().to_owned()
822        };
823        let raised = row(&h);
824        h.press("down");
825        let resting = row(&h);
826        assert!(resting.contains('…'), "{}", h.screen());
827        assert_eq!(raised, resting, "{}", h.screen());
828    }
829}