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