1use std::time::Duration;
5
6use super::context_item::{self as menu, ContextItem};
7use super::placement::{self, Placement};
8use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
9use crate::geometry::{Rect, Size, clamp_u16};
10use crate::keymap::{Key, Modifiers};
11use crate::motion::Easing;
12use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget};
13
14pub struct ContextMenu<Msg> {
59 items: Vec<ContextItem<Msg>>,
60 body: Vec<Node<Msg>>,
61 left_click: bool,
62}
63
64#[derive(Debug, Default)]
65struct ContextMenuMemory {
66 open: bool,
67 anchor: Rect,
68 levels: Vec<Option<usize>>,
70 opened_at: Vec<Duration>,
71 rects: Vec<Rect>,
72 last_pointer: Option<(i32, i32)>,
73 closed_by_press: Option<Duration>,
76}
77
78impl<Msg: Clone + 'static> ContextMenu<Msg> {
79 #[must_use]
82 pub fn new(items: impl IntoIterator<Item = ContextItem<Msg>>) -> Self {
83 Self {
84 items: items.into_iter().collect(),
85 body: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)],
86 left_click: false,
87 }
88 }
89
90 #[must_use]
95 pub fn on_left_click(mut self, on: bool) -> Self {
96 self.left_click = on;
97 self
98 }
99
100 fn opens_with(&self, button: MouseButton) -> bool {
102 !self.items.is_empty() && (button == MouseButton::Right || (button == MouseButton::Left && self.left_click))
103 }
104
105 fn level<'s>(&'s self, levels: &[Option<usize>], depth: usize) -> &'s [ContextItem<Msg>] {
107 let mut items: &[ContextItem<Msg>] = &self.items;
108 for highlight in levels.iter().take(depth) {
109 match highlight.and_then(|row| items.get(row)) {
110 Some(item) => items = item.children(),
111 None => return &[],
112 }
113 }
114 items
115 }
116
117 pub(crate) fn open(&self, cx: &mut EventCx<'_, Msg>, anchor: Rect, highlight_first: bool) {
120 let now = cx.now();
121 let first = if highlight_first { menu::edge(&self.items, false) } else { None };
122 let memory = cx.memory::<ContextMenuMemory>();
123 memory.open = true;
124 memory.anchor = anchor;
125 memory.levels = vec![first];
126 memory.opened_at = vec![now];
127 memory.rects.clear();
128 memory.last_pointer = None;
129 cx.capture_keys(true);
130 }
131
132 pub(crate) fn close(cx: &mut EventCx<'_, Msg>) {
134 let memory = cx.memory::<ContextMenuMemory>();
135 memory.open = false;
136 memory.levels.clear();
137 memory.opened_at.clear();
138 memory.rects.clear();
139 cx.capture_keys(false);
140 }
141
142 fn enter_submenu(&self, cx: &mut EventCx<'_, Msg>) -> bool {
144 let now = cx.now();
145 let levels = cx.memory::<ContextMenuMemory>().levels.clone();
146 let depth = levels.len() - 1;
147 let items = self.level(&levels, depth);
148 let Some(item) = levels[depth].and_then(|row| items.get(row)).filter(|item| item.has_submenu()) else {
149 return false;
150 };
151 let first = menu::edge(item.children(), false);
152 let memory = cx.memory::<ContextMenuMemory>();
153 memory.levels.push(first);
154 memory.opened_at.push(now);
155 true
156 }
157
158 fn activate(&self, cx: &mut EventCx<'_, Msg>) {
159 if self.enter_submenu(cx) {
160 return;
161 }
162 let levels = cx.memory::<ContextMenuMemory>().levels.clone();
163 let depth = levels.len() - 1;
164 let items = self.level(&levels, depth);
165 if let Some(message) = levels[depth].and_then(|row| items.get(row)).and_then(ContextItem::message) {
166 let message = message.clone();
167 Self::close(cx);
168 cx.emit(message);
169 }
170 }
171
172 fn set_highlight(cx: &mut EventCx<'_, Msg>, row: Option<usize>) {
173 if let Some(last) = cx.memory::<ContextMenuMemory>().levels.last_mut() {
174 *last = row;
175 }
176 }
177
178 fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent) -> bool {
179 let levels = cx.memory::<ContextMenuMemory>().levels.clone();
180 let depth = levels.len() - 1;
181 let items = self.level(&levels, depth);
182 let highlight = levels[depth];
183 if key.is_plain(Key::Esc) || (key.is_plain(Key::Left) && depth > 0) {
184 if depth == 0 {
185 Self::close(cx);
186 } else {
187 let memory = cx.memory::<ContextMenuMemory>();
188 memory.levels.pop();
189 memory.opened_at.pop();
190 }
191 } else if key.is_plain(Key::Tab) {
192 Self::close(cx);
193 return false;
194 } else if key.is_plain(Key::Up) {
195 Self::set_highlight(cx, menu::step(items, highlight, false));
196 } else if key.is_plain(Key::Down) {
197 Self::set_highlight(cx, menu::step(items, highlight, true));
198 } else if key.is_plain(Key::Home) {
199 Self::set_highlight(cx, menu::edge(items, false));
200 } else if key.is_plain(Key::End) {
201 Self::set_highlight(cx, menu::edge(items, true));
202 } else if key.is_plain(Key::Right) {
203 self.enter_submenu(cx);
204 } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
205 self.activate(cx);
206 } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt)
207 && let Some(row) = menu::type_ahead(items, highlight, typed)
208 {
209 Self::set_highlight(cx, Some(row));
210 }
211 true
212 }
213
214 fn press(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32, button: MouseButton) -> bool {
217 let (rects, levels) = open_rects(cx.memory::<ContextMenuMemory>());
218 let Some(depth) = rects.iter().rposition(|rect| rect.contains(x, y)) else {
220 Self::close(cx);
221 if !self.opens_with(button) || !cx.area().contains(x, y) {
222 return false;
223 }
224 cx.capture_pointer();
226 if button == MouseButton::Right {
228 self.open(cx, Rect::new(x, y, 1, 1), false);
229 }
230 return true;
231 };
232 cx.capture_pointer();
234 let row = usize::try_from(y - rects[depth].y).unwrap_or(0);
235 let items = self.level(&levels, depth);
236 if !items.get(row).is_some_and(ContextItem::selectable) {
237 return true;
238 }
239 let memory = cx.memory::<ContextMenuMemory>();
240 memory.levels.truncate(depth + 1);
241 memory.opened_at.truncate(depth + 1);
242 memory.levels[depth] = Some(row);
243 self.activate(cx);
244 true
245 }
246
247 fn follow_pointer(&self, cx: &mut PaintCx<'_>) {
250 let pointer = cx.pointer();
251 let now = cx.now();
252 let (moved, rects, mut levels) = {
253 let memory = cx.memory::<ContextMenuMemory>();
254 let moved = pointer.is_some() && pointer != memory.last_pointer;
255 memory.last_pointer = pointer;
256 let (rects, levels) = open_rects(memory);
257 (moved, rects, levels)
258 };
259 let Some((x, y)) = pointer.filter(|_| moved) else {
260 return;
261 };
262 let Some(depth) = rects.iter().rposition(|rect| rect.contains(x, y)) else {
263 return;
264 };
265 let row = usize::try_from(y - rects[depth].y).unwrap_or(0);
266 let item = self.level(&levels, depth).get(row).filter(|item| item.selectable());
267 let opens = item.is_some_and(ContextItem::has_submenu);
268 let unchanged = levels.get(depth) == Some(&item.map(|_| row)) && (levels.len() == depth + 2) == opens;
269 if unchanged {
270 return;
271 }
272 levels.truncate(depth + 1);
273 levels[depth] = item.map(|_| row);
274 let memory = cx.memory::<ContextMenuMemory>();
275 memory.opened_at.truncate(depth + 1);
276 if opens {
277 levels.push(None);
278 memory.opened_at.push(now);
279 }
280 memory.levels = levels;
281 }
282}
283
284pub(crate) fn is_menu_key(key: &KeyEvent) -> bool {
286 let shift = Modifiers { shift: true, ..Modifiers::default() };
287 (key.chord.key == Key::F(10) && key.chord.mods == shift) || key.is_plain(Key::Menu)
288}
289
290pub(crate) fn contains<Msg>(cx: &mut EventCx<'_, Msg>, x: i32, y: i32) -> bool {
293 let (rects, _) = open_rects(cx.memory::<ContextMenuMemory>());
294 rects.iter().any(|rect| rect.contains(x, y))
295}
296
297fn open_rects(memory: &ContextMenuMemory) -> (Vec<Rect>, Vec<Option<usize>>) {
301 let open = memory.rects.len().min(memory.levels.len());
302 (memory.rects[..open].to_vec(), memory.levels.clone())
303}
304
305pub(crate) fn is_open(cx: &mut PaintCx<'_>) -> bool {
308 cx.memory::<ContextMenuMemory>().open
309}
310
311pub(crate) fn is_open_in<Msg>(cx: &mut EventCx<'_, Msg>) -> bool {
313 cx.memory::<ContextMenuMemory>().open
314}
315
316impl<Msg: Clone + 'static> Container<Msg> for ContextMenu<Msg> {
317 fn set_children(&mut self, children: Vec<Node<Msg>>) {
318 let mut column = Node::new(Flex::new(Axis::Column, children), 0);
319 column.layout.width = Length::Fill(1);
320 column.layout.height = Length::Fill(1);
321 self.body = vec![column];
322 }
323}
324
325impl<Msg: Clone + 'static> Widget<Msg> for ContextMenu<Msg> {
326 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
327 self.body.first().map_or(Size::default(), |body| cx.measure_child(body, available))
328 }
329
330 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
331 cx.register_hit(area);
333 if let Some(body) = self.body.first() {
334 cx.paint_child(body, area);
335 }
336 if cx.memory::<ContextMenuMemory>().open {
337 cx.request_overlay(area);
338 }
339 }
340
341 fn paint_overlay(&self, cx: &mut PaintCx<'_>, _area: Rect) {
342 let screen = cx.clip();
343 self.follow_pointer(cx);
346 let (anchor, levels, opened_at) = {
347 let memory = cx.memory::<ContextMenuMemory>();
348 (memory.anchor, memory.levels.clone(), memory.opened_at.clone())
349 };
350 let enter = cx.env().theme().motion().enter;
351 let mut rects = Vec::new();
352 let mut parent_row = anchor;
353 for depth in 0..levels.len() {
354 let items = self.level(&levels, depth);
355 if items.is_empty() {
356 break;
357 }
358 let preferred = if depth == 0 { Placement::Below } else { Placement::Right };
359 let (full, side) = placement::place(parent_row, menu::size(cx, items), screen, preferred);
360 let started = opened_at.get(depth).copied().unwrap_or_default();
361 let progress = cx.progress_since(started, enter, Easing::EaseOut);
362 let shown =
364 placement::unfold(full, if side == Placement::Above { side } else { Placement::Below }, progress);
365 menu::paint(cx, items, full, shown, levels[depth]);
366 rects.push(full);
367 let Some(row) = levels[depth] else {
368 break;
369 };
370 parent_row = full.row(clamp_u16(i32::try_from(row).unwrap_or(i32::MAX)));
371 }
372 cx.memory::<ContextMenuMemory>().rects = rects;
373 }
374
375 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
376 let open = cx.memory::<ContextMenuMemory>().open;
377 match event {
378 Event::PointerOutside => {
379 let on_area = self.left_click
382 && open
383 && cx.interaction.pointer.is_some_and(|(x, y)| {
384 cx.area().contains(x, y)
385 && !cx.memory::<ContextMenuMemory>().rects.iter().any(|rect| rect.contains(x, y))
386 });
387 Self::close(cx);
388 if on_area {
389 cx.memory::<ContextMenuMemory>().closed_by_press = Some(cx.now());
390 }
391 true
392 }
393 Event::Key(key) if open => self.key(cx, key),
394 Event::Key(key) => {
395 if !is_menu_key(key) || self.items.is_empty() {
396 return false;
397 }
398 let area = cx.area();
399 let anchor =
400 cx.focused_area().filter(|rect| !rect.is_empty()).unwrap_or(Rect::new(area.x, area.y, 1, 1));
401 self.open(cx, anchor, true);
402 true
403 }
404 Event::Mouse(mouse) => match mouse.kind {
405 MouseKind::Down(button) if open => self.press(cx, mouse.x, mouse.y, button),
406 MouseKind::Down(button) if self.opens_with(button) => {
407 let now = cx.now();
408 let closed = cx.memory::<ContextMenuMemory>().closed_by_press.take();
409 if button == MouseButton::Left && closed == Some(now) {
410 return true;
411 }
412 self.open(cx, Rect::new(mouse.x, mouse.y, 1, 1), false);
413 true
414 }
415 MouseKind::Up(_) | MouseKind::Drag(_) | MouseKind::ScrollUp | MouseKind::ScrollDown => open,
416 _ => false,
417 },
418 Event::Paste(_) => false,
419 }
420 }
421
422 fn children(&self) -> &[Node<Msg>] {
423 &self.body
424 }
425
426 fn children_mut(&mut self) -> &mut [Node<Msg>] {
427 &mut self.body
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::runtime::{App, Command, Harness};
435 use crate::widget::View;
436 use crate::widgets::{Button, Text};
437
438 #[derive(Default)]
439 struct Demo {
440 chosen: Vec<&'static str>,
441 pressed: u32,
442 }
443
444 #[derive(Clone)]
445 enum Msg {
446 Chose(&'static str),
447 Press,
448 }
449
450 impl App for Demo {
451 type Msg = Msg;
452 fn update(&mut self, msg: Msg) -> Command<Msg> {
453 match msg {
454 Msg::Chose(name) => self.chosen.push(name),
455 Msg::Press => self.pressed += 1,
456 }
457 Command::none()
458 }
459 fn view(&self, ui: &mut View<'_, Msg>) {
460 let items = [
461 ContextItem::new("Restart", Msg::Chose("restart")).shortcut("ctrl r"),
462 ContextItem::new("Pause", Msg::Chose("pause")).disabled(true),
463 ContextItem::submenu("Move to", [ContextItem::new("Staging", Msg::Chose("staging"))]),
464 ContextItem::gap(),
465 ContextItem::new("Delete", Msg::Chose("delete")).danger(true),
466 ];
467 ui.column(|ui| {
468 ui.add_with(ContextMenu::new(items), |ui| {
469 ui.add(Text::new("api-gateway"));
470 ui.add(Button::new("Logs").on_press(Msg::Press)).id("logs");
471 })
472 .height(Length::Cells(10))
473 .fill_width();
474 })
475 .fill_width();
476 }
477 }
478
479 fn right_click<A: App>(h: &mut Harness<A>, x: i32, y: i32) {
480 h.mouse(MouseKind::Down(MouseButton::Right), x, y);
481 h.mouse(MouseKind::Up(MouseButton::Right), x, y);
482 }
483
484 #[test]
485 fn right_click_opens_at_the_pointer_with_shortcuts_anchored_right() {
486 let mut h = Harness::new(Demo::default(), 40, 12);
487 right_click(&mut h, 3, 0);
488 h.advance(Duration::from_millis(200));
489 let screen = h.screen();
490 let lines: Vec<&str> = screen.lines().collect();
491 assert_eq!(lines[1], " L Restart ctrl r", "{screen}");
493 assert_eq!(lines[2], " Pause");
494 assert_eq!(lines[3], " Move to ▶");
495 assert_eq!(lines[4], "");
496 assert_eq!(lines[5], " Delete");
497 let theme = h.env().theme();
498 assert_eq!(h.bg(3, 1), theme.color("overlay"));
499 assert_eq!(h.fg(5, 5), theme.color("danger"));
500 assert_eq!(h.fg(5, 2), theme.color("muted"));
501 assert_eq!(h.fg(16, 1), theme.color("muted"));
502 }
503
504 #[test]
505 fn keyboard_skips_disabled_rows_and_gaps_and_chooses() {
506 let mut h = Harness::new(Demo::default(), 40, 12);
507 h.set_reduced_motion(true);
508 right_click(&mut h, 3, 0);
509 h.press("down").press("down");
510 let screen = h.screen();
511 assert!(screen.lines().nth(3).is_some_and(|line| line.starts_with(" ▌ Move to")), "{screen}");
512 h.press("down").press("enter");
513 assert_eq!(h.app().chosen, vec!["delete"]);
514 assert!(!h.screen().contains("Restart"));
515 }
516
517 #[test]
518 fn submenus_open_with_right_and_close_with_left() {
519 let mut h = Harness::new(Demo::default(), 40, 12);
520 h.set_reduced_motion(true);
521 right_click(&mut h, 3, 0);
522 h.press("m").press("right");
523 assert!(h.screen().contains("Staging"), "{}", h.screen());
524 h.press("left");
525 assert!(!h.screen().contains("Staging"));
526 h.press("enter").press("enter");
527 assert_eq!(h.app().chosen, vec!["staging"]);
528 }
529
530 #[test]
531 fn hovering_a_submenu_opens_it_and_clicks_choose() {
532 let mut h = Harness::new(Demo::default(), 40, 12);
533 h.set_reduced_motion(true);
534 right_click(&mut h, 3, 0);
535 h.hover(8, 3);
536 let (x, y) = h.find("Staging").expect("submenu opened on hover");
537 h.click(x, y);
538 assert_eq!(h.app().chosen, vec!["staging"]);
539 }
540
541 #[test]
542 fn outside_press_closes_and_still_reaches_the_button() {
543 let mut h = Harness::new(Demo::default(), 40, 12);
544 h.set_reduced_motion(true);
545 right_click(&mut h, 20, 8);
546 assert!(h.screen().contains("Restart"));
547 h.click_text("Logs");
548 assert_eq!(h.app().pressed, 1, "one press closes the menu and presses the button");
549 assert!(!h.screen().contains("Restart"));
550 assert!(h.app().chosen.is_empty());
551 }
552
553 #[test]
554 fn shift_f10_opens_next_to_the_focused_widget() {
555 let mut h = Harness::new(Demo::default(), 40, 12);
556 h.set_reduced_motion(true);
557 h.press("tab").press("shift+f10");
558 let screen = h.screen();
559 assert!(screen.lines().nth(2).is_some_and(|line| line.starts_with("▌ Restart ctrl r")), "{screen}");
560 h.press("esc");
561 assert!(!h.screen().contains("Restart"));
562 assert!(h.is_focused("logs"));
563 }
564
565 #[test]
566 fn flips_above_near_the_bottom() {
567 let mut h = Harness::new(Demo::default(), 40, 12);
568 h.set_reduced_motion(true);
569 right_click(&mut h, 3, 9);
570 let (_, y) = h.find("Delete").expect("open");
571 assert_eq!(y, 8);
572 }
573
574 #[derive(Default)]
576 struct Sessions {
577 chosen: Vec<&'static str>,
578 }
579
580 impl App for Sessions {
581 type Msg = Msg;
582 fn update(&mut self, msg: Msg) -> Command<Msg> {
583 if let Msg::Chose(name) = msg {
584 self.chosen.push(name);
585 }
586 Command::none()
587 }
588 fn view(&self, ui: &mut View<'_, Msg>) {
589 let items = [
590 ContextItem::new("work", Msg::Chose("work")),
591 ContextItem::new("notes", Msg::Chose("notes")),
592 ContextItem::new("build", Msg::Chose("build")),
593 ];
594 ui.column(|ui| {
595 ui.add_with(ContextMenu::new(items).on_left_click(true), |ui| {
596 ui.add(Text::new("tmux 3"));
597 });
598 })
599 .fill();
600 }
601 }
602
603 fn sessions() -> Harness<Sessions> {
604 let mut h = Harness::new(Sessions::default(), 30, 8);
605 h.set_reduced_motion(true);
606 h
607 }
608
609 #[test]
610 fn a_left_click_opens_the_menu_at_the_pointer_and_a_click_chooses() {
611 let mut by_right = sessions();
612 right_click(&mut by_right, 2, 0);
613 let mut h = sessions();
614 h.click(2, 0);
615 let screen = h.screen();
616 let lines: Vec<&str> = screen.lines().collect();
617 assert_eq!(lines[1], " work", "{screen}");
619 assert_eq!(lines[3], " build");
620 assert_eq!(screen, by_right.screen());
621 assert_eq!(h.bg(3, 1), h.env().theme().color("overlay"));
622 h.click_text("notes");
623 assert_eq!(h.app().chosen, vec!["notes"]);
624 assert!(!h.screen().contains("build"), "choosing closes the menu");
625 }
626
627 #[test]
628 fn a_left_click_menu_takes_the_keys_and_esc_closes_it() {
629 let mut h = sessions();
630 h.click(2, 0);
631 h.press("down").press("down").press("enter");
632 assert_eq!(h.app().chosen, vec!["notes"]);
633 h.click(2, 0);
634 assert!(h.screen().contains("build"));
635 h.press("esc");
636 assert!(!h.screen().contains("build"));
637 assert!(h.app().chosen.len() == 1, "esc chooses nothing");
638 }
639
640 #[test]
641 fn a_second_left_click_on_the_area_closes_and_the_right_click_still_opens() {
642 let mut h = sessions();
643 h.click(2, 0);
644 assert!(h.screen().contains("work"));
645 h.click(4, 0);
646 assert!(!h.screen().contains("work"), "the item works as a button that shows and hides the menu");
647 right_click(&mut h, 2, 0);
648 h.click_text("build");
649 assert_eq!(h.app().chosen, vec!["build"]);
650 }
651
652 #[derive(Default)]
654 struct Hinted {
655 chosen: Vec<&'static str>,
656 }
657
658 impl App for Hinted {
659 type Msg = Msg;
660 fn update(&mut self, msg: Msg) -> Command<Msg> {
661 if let Msg::Chose(name) = msg {
662 self.chosen.push(name);
663 }
664 Command::none()
665 }
666 fn view(&self, ui: &mut View<'_, Msg>) {
667 let items = [ContextItem::new("work", Msg::Chose("work")), ContextItem::new("notes", Msg::Chose("notes"))];
668 ui.column(|ui| {
669 ui.add_with(ContextMenu::new(items).on_left_click(true), |ui| {
670 ui.add_with(crate::widgets::Tooltip::new("tmux sessions"), |ui| {
671 ui.add(Text::new("tmux 2"));
672 });
673 });
674 })
675 .fill();
676 }
677 }
678
679 #[test]
680 fn a_second_left_click_closes_even_when_the_item_takes_the_pointer_itself() {
681 let mut h = Harness::new(Hinted::default(), 30, 8);
682 h.set_reduced_motion(true);
683 h.click(2, 0);
684 assert!(h.screen().contains("notes"), "the first click opens:\n{}", h.screen());
685 h.click(2, 0);
686 assert!(!h.screen().contains("notes"), "the second click on the same place closes:\n{}", h.screen());
687 h.click(2, 0);
688 assert!(h.screen().contains("notes"), "and a third opens again:\n{}", h.screen());
689 }
690
691 #[test]
692 fn a_left_click_opens_nothing_unless_asked() {
693 let mut h = Harness::new(Demo::default(), 40, 12);
694 h.set_reduced_motion(true);
695 h.click(3, 0);
696 assert!(!h.screen().contains("Restart"), "{}", h.screen());
697 }
698
699 struct Notes;
701
702 impl App for Notes {
703 type Msg = Msg;
704 fn update(&mut self, _msg: Msg) -> Command<Msg> {
705 Command::none()
706 }
707 fn view(&self, ui: &mut View<'_, Msg>) {
708 let items = [
709 ContextItem::new("Open", Msg::Chose("open")),
710 ContextItem::new("Compress", Msg::Chose("compress")).detail("slow").shortcut("ctrl k"),
711 ContextItem::new("Extract here", Msg::Chose("extract")).detail("bsdtar needed").disabled(true),
712 ];
713 ui.add_with(ContextMenu::new(items), |ui| {
714 ui.add(Text::new("archive.tar"));
715 })
716 .fill();
717 }
718 }
719
720 fn fg_at<A: App>(h: &Harness<A>, x: i32, y: i32) -> Option<crate::color::Rgb> {
722 h.fg(u16::try_from(x).unwrap(), u16::try_from(y).unwrap())
723 }
724
725 fn open_notes(width: u16) -> Harness<Notes> {
726 let mut h = Harness::new(Notes, width, 8);
727 h.set_reduced_motion(true);
728 right_click(&mut h, 0, 0);
729 h
730 }
731
732 #[test]
733 fn a_note_sits_on_the_right_in_the_muted_tone_also_on_a_disabled_row() {
734 let h = open_notes(60);
735 let screen = h.screen();
736 let (x, y) = h.find("bsdtar needed").expect("the note of the disabled row is drawn");
737 let muted = h.env().theme().color("muted");
738 assert_eq!(fg_at(&h, x, y), muted, "{screen}");
739 assert_eq!(fg_at(&h, x + 12, y), muted);
740 let line = screen.lines().nth(usize::try_from(y).unwrap()).unwrap();
741 assert_eq!(line.trim(), "Extract here bsdtar needed", "plain text, no brackets: {screen}");
742 }
743
744 #[test]
745 fn the_shortcut_stays_outermost_with_the_note_before_it() {
746 let h = open_notes(60);
747 let screen = h.screen();
748 let (note_x, y) = h.find("slow").expect("note drawn");
749 let (key_x, key_y) = h.find("ctrl k").expect("shortcut drawn");
750 assert_eq!(y, key_y);
751 assert_eq!(key_x, note_x + 4 + 2, "two cells between the note and the shortcut: {screen}");
752 assert_eq!(fg_at(&h, note_x, y), h.env().theme().color("muted"));
753 let (other_x, _) = h.find("bsdtar needed").expect("note drawn");
755 assert_eq!(key_x + 6, other_x + 13, "{screen}");
756 }
757
758 #[test]
759 fn a_narrow_screen_cuts_the_note_before_the_label() {
760 let h = open_notes(24);
761 let screen = h.screen();
762 assert!(screen.contains("Extract here"), "the label is whole: {screen}");
763 assert!(!screen.contains("bsdtar needed"), "{screen}");
764 let (x, y) = h.find("bsd").expect("the note is cut, not dropped, while it has room");
765 assert!(screen.lines().nth(usize::try_from(y).unwrap()).unwrap().contains('…'), "{screen}");
766 assert_eq!(fg_at(&h, x, y), h.env().theme().color("muted"));
767
768 let h = open_notes(18);
769 let screen = h.screen();
770 assert!(screen.contains("Extract here"), "the label is still whole: {screen}");
771 assert!(!screen.contains("bsd"), "with no room left the note goes: {screen}");
772 }
773}