1use 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
18const DEFAULT_WIDTH: u16 = 72;
20
21const MAX_ROWS: u16 = 10;
23
24const HIDDEN_ACTIONS: [&str; 3] = ["focus-next", "focus-prev", "palette"];
27
28type IdMessage<Msg> = Box<dyn Fn(&str) -> Msg>;
30
31pub struct PaletteCommand<Msg> {
33 id: String,
34 label: String,
35 chord: Option<String>,
36 message: Msg,
37}
38
39impl<Msg> PaletteCommand<Msg> {
40 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum Target {
58 Command(usize),
60 Action(usize),
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66enum Row {
67 Header(String),
68 Entry { target: Target, positions: Vec<usize> },
69}
70
71#[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 highlight: usize,
85 offset: usize,
86 visible: usize,
87 list: Rect,
88 pointer: PointerGate,
90 bar: BarDrag,
91}
92
93pub 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 #[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 #[must_use]
151 pub fn keymap(mut self, include: bool) -> Self {
152 self.keymap = include;
153 self
154 }
155
156 #[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 #[must_use]
165 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
166 self.placeholder = Some(text.into());
167 self
168 }
169
170 #[must_use]
172 pub fn width(mut self, cells: u16) -> Self {
173 self.width = cells;
174 self
175 }
176
177 #[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 #[must_use]
189 pub fn dismissable(mut self, dismissable: bool) -> Self {
190 self.dismissable = dismissable;
191 self
192 }
193
194 #[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 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 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 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 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 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 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 let moved = memory.pointer.moved(pointer);
348 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 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 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 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 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}