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 dismissable: bool,
124 on_close: Msg,
125 on_run: Option<IdMessage<Msg>>,
126}
127
128impl<Msg: Clone + 'static> CommandPalette<Msg> {
129 #[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 #[must_use]
149 pub fn keymap(mut self, include: bool) -> Self {
150 self.keymap = include;
151 self
152 }
153
154 #[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 #[must_use]
163 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
164 self.placeholder = Some(text.into());
165 self
166 }
167
168 #[must_use]
170 pub fn width(mut self, cells: u16) -> Self {
171 self.width = cells;
172 self
173 }
174
175 #[must_use]
179 pub fn dismissable(mut self, dismissable: bool) -> Self {
180 self.dismissable = dismissable;
181 self
182 }
183
184 #[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 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 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 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 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 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 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 let moved = memory.pointer.moved(pointer);
337 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 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 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 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 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}