Skip to main content

qframe/widgets/
context_menu.rs

1//! Context menus: actions for what is under the pointer, opened with a right click (or, when
2//! asked, a left click).
3
4use 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
14/// Wraps an area and opens a menu of [`ContextItem`]s at the pointer on a right click, or next to
15/// the focused widget on Shift+F10 or the menu key.
16///
17/// The menu is a layer on the overlay surface that unfolds over `motion.enter`. Hovered and
18/// highlighted rows show the pillar and slide their label one cell while shortcuts stay at the
19/// right edge. ↑/↓ move (skipping gaps and disabled rows), Home/End jump, typing a letter jumps to
20/// the next row starting with it, → or Enter opens a submenu, ← or Esc closes it, Enter or Space
21/// chooses. Choosing sends the item's message. A press anywhere outside the menu closes it and
22/// still reaches what it landed on; a right click inside the area reopens it there.
23///
24/// [`ContextMenu::on_left_click`] lets a left click open the menu too, at the pointer, for an
25/// area whose whole purpose is the menu, such as a status bar item that lists sessions.
26///
27/// Style keys: see [`ContextItem`].
28///
29/// ```
30/// use qframe::prelude::*;
31/// use qframe::widgets::{ContextMenu, ContextItem};
32///
33/// struct Files { deleted: bool }
34///
35/// #[derive(Clone)]
36/// enum Msg { Delete }
37///
38/// impl App for Files {
39///     type Msg = Msg;
40///     fn update(&mut self, Msg::Delete: Msg) -> Command<Msg> {
41///         self.deleted = true;
42///         Command::none()
43///     }
44///     fn view(&self, ui: &mut View<'_, Msg>) {
45///         let items = [ContextItem::new("Delete", Msg::Delete).danger(true)];
46///         ui.add_with(ContextMenu::new(items), |ui| {
47///             ui.add(Text::new("report.pdf"));
48///         });
49///     }
50/// }
51///
52/// let mut app = Harness::new(Files { deleted: false }, 30, 5);
53/// app.set_reduced_motion(true);
54/// app.mouse(qframe::event::MouseKind::Down(qframe::event::MouseButton::Right), 2, 0);
55/// app.press("down").press("enter");
56/// assert!(app.app().deleted);
57/// ```
58pub 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    /// The highlighted row of every open level; the last level has the keyboard.
69    levels: Vec<Option<usize>>,
70    opened_at: Vec<Duration>,
71    rects: Vec<Rect>,
72    last_pointer: Option<(i32, i32)>,
73    /// When a left press on the menu's own area closed it: the same press reaches the area next
74    /// and must not open the menu again, so a status item shows and hides its menu like a button.
75    closed_by_press: Option<Duration>,
76}
77
78impl<Msg: Clone + 'static> ContextMenu<Msg> {
79    /// A context menu of `items` over the widgets added with
80    /// [`View::add_with`](crate::widget::View::add_with).
81    #[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    /// Opens the menu with a left click as well, at the pointer, as a right click does. A left
91    /// click on the area while the menu is open closes it, so the area works as a button that
92    /// shows and hides its menu. Presses an interactive child takes, such as a button's, still
93    /// go to that child. Default: off; a left click reaches only the content.
94    #[must_use]
95    pub fn on_left_click(mut self, on: bool) -> Self {
96        self.left_click = on;
97        self
98    }
99
100    /// Whether a press of `button` on the area opens the menu.
101    fn opens_with(&self, button: MouseButton) -> bool {
102        !self.items.is_empty() && (button == MouseButton::Right || (button == MouseButton::Left && self.left_click))
103    }
104
105    /// The items shown at `depth` for the open levels in `levels`.
106    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    /// Opens the menu below `anchor`, with the first choosable row highlighted when
118    /// `highlight_first` (for menus opened from the keyboard), and takes the keys.
119    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    /// Closes the menu and gives the keys back.
133    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    /// Opens the submenu of the highlighted row of the last level, if it has one.
143    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    /// Handles a press while open. Returns whether the press was used; a left press on the area
215    /// outside the menu only closes it and goes on to what lies there.
216    fn press(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32, button: MouseButton) -> bool {
217        let (rects, levels) = open_rects(cx.memory::<ContextMenuMemory>());
218        // A press outside the menu closes it and is not swallowed.
219        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            // The release after this press belongs to the menu, not to what lies under it.
225            cx.capture_pointer();
226            // A left click on the area toggles: the press that closes the menu does not reopen it.
227            if button == MouseButton::Right {
228                self.open(cx, Rect::new(x, y, 1, 1), false);
229            }
230            return true;
231        };
232        // The release after this press belongs to the menu, not to what lies under it.
233        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    /// Follows the pointer: the row under it is highlighted and a submenu under it opens. Only
248    /// when the pointer moved, so a resting pointer does not undo keyboard moves.
249    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
284/// Whether `key` opens a context menu from the keyboard: Shift+F10 or the menu key.
285pub(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
290/// Whether `(x, y)` is on a level of the context menu kept in the memory of the widget handling an
291/// event, as painted last frame.
292pub(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
297/// The rects of the levels painted last frame that are still open, and the open levels. Events
298/// arrive in batches between frames, so a key that closed a submenu can come before a press on
299/// where that submenu was drawn.
300fn 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
305// Menus driven by the widget that owns them, for the edit menus of text widgets.
306/// Whether the context menu kept in the memory of the widget being painted is open.
307pub(crate) fn is_open(cx: &mut PaintCx<'_>) -> bool {
308    cx.memory::<ContextMenuMemory>().open
309}
310
311/// Whether the context menu kept in the memory of the widget handling an event is open.
312pub(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        // Plain content still receives the right click; interactive children sit on top.
332        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        // Only the menu rows take presses (`menu::paint` registers them); the runtime closes the
344        // menu on a press anywhere else and passes that press on.
345        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            // Submenus beside their row unfold downwards too.
363            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                // A press on a widget inside the area that takes the pointer itself, a tooltip
380                // for one, comes here first; the same press then reaches the area.
381                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        // The menu opens one row under the pointer and covers what is below it.
492        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    /// A status bar item whose left click lists sessions, as a menu of its own.
575    #[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        // One row under the pointer, starting at its column, exactly as a right click opens it.
618        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    /// A status item whose content takes the pointer itself to show a tooltip, as qdesk's does.
653    #[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    /// A menu whose rows carry notes: one beside a shortcut, one on a disabled row.
700    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    /// The foreground at a cell `find` returned.
721    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        // The shortcut ends where the other rows' notes end: at the menu's right margin.
754        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}