Skip to main content

qframe/widgets/
context_menu.rs

1//! Context menus: actions for what is under the pointer, opened with a right click.
2
3use std::time::Duration;
4
5use super::context_item::{self as menu, ContextItem};
6use super::placement::{self, Placement};
7use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
8use crate::geometry::{Rect, Size, clamp_u16};
9use crate::keymap::{Key, Modifiers};
10use crate::motion::Easing;
11use crate::widget::{Axis, Container, EventCx, Flex, Length, MeasureCx, Node, PaintCx, Widget};
12
13/// Wraps an area and opens a menu of [`ContextItem`]s at the pointer on a right click, or next to
14/// the focused widget on Shift+F10 or the menu key.
15///
16/// The menu is a layer on the overlay surface that unfolds over `motion.enter`. Hovered and
17/// highlighted rows show the pillar and slide their label one cell while shortcuts stay at the
18/// right edge. ↑/↓ move (skipping gaps and disabled rows), Home/End jump, typing a letter jumps to
19/// the next row starting with it, → or Enter opens a submenu, ← or Esc closes it, Enter or Space
20/// chooses. Choosing sends the item's message. A press anywhere outside the menu closes it and
21/// still reaches what it landed on; a right click inside the area reopens it there.
22///
23/// Style keys: see [`ContextItem`].
24///
25/// ```
26/// use qframe::prelude::*;
27/// use qframe::widgets::{ContextMenu, ContextItem};
28///
29/// struct Files { deleted: bool }
30///
31/// #[derive(Clone)]
32/// enum Msg { Delete }
33///
34/// impl App for Files {
35///     type Msg = Msg;
36///     fn update(&mut self, Msg::Delete: Msg) -> Command<Msg> {
37///         self.deleted = true;
38///         Command::none()
39///     }
40///     fn view(&self, ui: &mut View<'_, Msg>) {
41///         let items = [ContextItem::new("Delete", Msg::Delete).danger(true)];
42///         ui.add_with(ContextMenu::new(items), |ui| {
43///             ui.add(Text::new("report.pdf"));
44///         });
45///     }
46/// }
47///
48/// let mut app = Harness::new(Files { deleted: false }, 30, 5);
49/// app.set_reduced_motion(true);
50/// app.mouse(qframe::event::MouseKind::Down(qframe::event::MouseButton::Right), 2, 0);
51/// app.press("down").press("enter");
52/// assert!(app.app().deleted);
53/// ```
54pub struct ContextMenu<Msg> {
55    items: Vec<ContextItem<Msg>>,
56    body: Vec<Node<Msg>>,
57}
58
59#[derive(Debug, Default)]
60struct ContextMenuMemory {
61    open: bool,
62    anchor: Rect,
63    /// The highlighted row of every open level; the last level has the keyboard.
64    levels: Vec<Option<usize>>,
65    opened_at: Vec<Duration>,
66    rects: Vec<Rect>,
67    last_pointer: Option<(i32, i32)>,
68}
69
70impl<Msg: Clone + 'static> ContextMenu<Msg> {
71    /// A context menu of `items` over the widgets added with
72    /// [`View::add_with`](crate::widget::View::add_with).
73    #[must_use]
74    pub fn new(items: impl IntoIterator<Item = ContextItem<Msg>>) -> Self {
75        Self { items: items.into_iter().collect(), body: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)] }
76    }
77
78    /// The items shown at `depth` for the open levels in `levels`.
79    fn level<'s>(&'s self, levels: &[Option<usize>], depth: usize) -> &'s [ContextItem<Msg>] {
80        let mut items: &[ContextItem<Msg>] = &self.items;
81        for highlight in levels.iter().take(depth) {
82            match highlight.and_then(|row| items.get(row)) {
83                Some(item) => items = item.children(),
84                None => return &[],
85            }
86        }
87        items
88    }
89
90    /// Opens the menu below `anchor`, with the first choosable row highlighted when
91    /// `highlight_first` (for menus opened from the keyboard), and takes the keys.
92    pub(crate) fn open(&self, cx: &mut EventCx<'_, Msg>, anchor: Rect, highlight_first: bool) {
93        let now = cx.now();
94        let first = if highlight_first { menu::edge(&self.items, false) } else { None };
95        let memory = cx.memory::<ContextMenuMemory>();
96        memory.open = true;
97        memory.anchor = anchor;
98        memory.levels = vec![first];
99        memory.opened_at = vec![now];
100        memory.rects.clear();
101        memory.last_pointer = None;
102        cx.capture_keys(true);
103    }
104
105    /// Closes the menu and gives the keys back.
106    pub(crate) fn close(cx: &mut EventCx<'_, Msg>) {
107        let memory = cx.memory::<ContextMenuMemory>();
108        memory.open = false;
109        memory.levels.clear();
110        memory.opened_at.clear();
111        memory.rects.clear();
112        cx.capture_keys(false);
113    }
114
115    /// Opens the submenu of the highlighted row of the last level, if it has one.
116    fn enter_submenu(&self, cx: &mut EventCx<'_, Msg>) -> bool {
117        let now = cx.now();
118        let levels = cx.memory::<ContextMenuMemory>().levels.clone();
119        let depth = levels.len() - 1;
120        let items = self.level(&levels, depth);
121        let Some(item) = levels[depth].and_then(|row| items.get(row)).filter(|item| item.has_submenu()) else {
122            return false;
123        };
124        let first = menu::edge(item.children(), false);
125        let memory = cx.memory::<ContextMenuMemory>();
126        memory.levels.push(first);
127        memory.opened_at.push(now);
128        true
129    }
130
131    fn activate(&self, cx: &mut EventCx<'_, Msg>) {
132        if self.enter_submenu(cx) {
133            return;
134        }
135        let levels = cx.memory::<ContextMenuMemory>().levels.clone();
136        let depth = levels.len() - 1;
137        let items = self.level(&levels, depth);
138        if let Some(message) = levels[depth].and_then(|row| items.get(row)).and_then(ContextItem::message) {
139            let message = message.clone();
140            Self::close(cx);
141            cx.emit(message);
142        }
143    }
144
145    fn set_highlight(cx: &mut EventCx<'_, Msg>, row: Option<usize>) {
146        if let Some(last) = cx.memory::<ContextMenuMemory>().levels.last_mut() {
147            *last = row;
148        }
149    }
150
151    fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent) -> bool {
152        let levels = cx.memory::<ContextMenuMemory>().levels.clone();
153        let depth = levels.len() - 1;
154        let items = self.level(&levels, depth);
155        let highlight = levels[depth];
156        if key.is_plain(Key::Esc) || (key.is_plain(Key::Left) && depth > 0) {
157            if depth == 0 {
158                Self::close(cx);
159            } else {
160                let memory = cx.memory::<ContextMenuMemory>();
161                memory.levels.pop();
162                memory.opened_at.pop();
163            }
164        } else if key.is_plain(Key::Tab) {
165            Self::close(cx);
166            return false;
167        } else if key.is_plain(Key::Up) {
168            Self::set_highlight(cx, menu::step(items, highlight, false));
169        } else if key.is_plain(Key::Down) {
170            Self::set_highlight(cx, menu::step(items, highlight, true));
171        } else if key.is_plain(Key::Home) {
172            Self::set_highlight(cx, menu::edge(items, false));
173        } else if key.is_plain(Key::End) {
174            Self::set_highlight(cx, menu::edge(items, true));
175        } else if key.is_plain(Key::Right) {
176            self.enter_submenu(cx);
177        } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
178            self.activate(cx);
179        } else if let (Some(typed), false) = (key.text, key.chord.mods.ctrl || key.chord.mods.alt)
180            && let Some(row) = menu::type_ahead(items, highlight, typed)
181        {
182            Self::set_highlight(cx, Some(row));
183        }
184        true
185    }
186
187    /// Handles a press while open. Returns whether the press was used; a left press on the area
188    /// outside the menu only closes it and goes on to what lies there.
189    fn press(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32, button: MouseButton) -> bool {
190        let (rects, levels) = open_rects(cx.memory::<ContextMenuMemory>());
191        // A press outside the menu closes it and is not swallowed.
192        let Some(depth) = rects.iter().rposition(|rect| rect.contains(x, y)) else {
193            Self::close(cx);
194            if button != MouseButton::Right || !cx.area().contains(x, y) {
195                return false;
196            }
197            cx.capture_pointer();
198            self.open(cx, Rect::new(x, y, 1, 1), false);
199            return true;
200        };
201        // The release after this press belongs to the menu, not to what lies under it.
202        cx.capture_pointer();
203        let row = usize::try_from(y - rects[depth].y).unwrap_or(0);
204        let items = self.level(&levels, depth);
205        if !items.get(row).is_some_and(ContextItem::selectable) {
206            return true;
207        }
208        let memory = cx.memory::<ContextMenuMemory>();
209        memory.levels.truncate(depth + 1);
210        memory.opened_at.truncate(depth + 1);
211        memory.levels[depth] = Some(row);
212        self.activate(cx);
213        true
214    }
215
216    /// Follows the pointer: the row under it is highlighted and a submenu under it opens. Only
217    /// when the pointer moved, so a resting pointer does not undo keyboard moves.
218    fn follow_pointer(&self, cx: &mut PaintCx<'_>) {
219        let pointer = cx.pointer();
220        let now = cx.now();
221        let (moved, rects, mut levels) = {
222            let memory = cx.memory::<ContextMenuMemory>();
223            let moved = pointer.is_some() && pointer != memory.last_pointer;
224            memory.last_pointer = pointer;
225            let (rects, levels) = open_rects(memory);
226            (moved, rects, levels)
227        };
228        let Some((x, y)) = pointer.filter(|_| moved) else {
229            return;
230        };
231        let Some(depth) = rects.iter().rposition(|rect| rect.contains(x, y)) else {
232            return;
233        };
234        let row = usize::try_from(y - rects[depth].y).unwrap_or(0);
235        let item = self.level(&levels, depth).get(row).filter(|item| item.selectable());
236        let opens = item.is_some_and(ContextItem::has_submenu);
237        let unchanged = levels.get(depth) == Some(&item.map(|_| row)) && (levels.len() == depth + 2) == opens;
238        if unchanged {
239            return;
240        }
241        levels.truncate(depth + 1);
242        levels[depth] = item.map(|_| row);
243        let memory = cx.memory::<ContextMenuMemory>();
244        memory.opened_at.truncate(depth + 1);
245        if opens {
246            levels.push(None);
247            memory.opened_at.push(now);
248        }
249        memory.levels = levels;
250    }
251}
252
253/// Whether `key` opens a context menu from the keyboard: Shift+F10 or the menu key.
254pub(crate) fn is_menu_key(key: &KeyEvent) -> bool {
255    let shift = Modifiers { shift: true, ..Modifiers::default() };
256    (key.chord.key == Key::F(10) && key.chord.mods == shift) || key.is_plain(Key::Menu)
257}
258
259/// Whether `(x, y)` is on a level of the context menu kept in the memory of the widget handling an
260/// event, as painted last frame.
261pub(crate) fn contains<Msg>(cx: &mut EventCx<'_, Msg>, x: i32, y: i32) -> bool {
262    let (rects, _) = open_rects(cx.memory::<ContextMenuMemory>());
263    rects.iter().any(|rect| rect.contains(x, y))
264}
265
266/// The rects of the levels painted last frame that are still open, and the open levels. Events
267/// arrive in batches between frames, so a key that closed a submenu can come before a press on
268/// where that submenu was drawn.
269fn open_rects(memory: &ContextMenuMemory) -> (Vec<Rect>, Vec<Option<usize>>) {
270    let open = memory.rects.len().min(memory.levels.len());
271    (memory.rects[..open].to_vec(), memory.levels.clone())
272}
273
274// Menus driven by the widget that owns them, for the edit menus of text widgets.
275/// Whether the context menu kept in the memory of the widget being painted is open.
276pub(crate) fn is_open(cx: &mut PaintCx<'_>) -> bool {
277    cx.memory::<ContextMenuMemory>().open
278}
279
280/// Whether the context menu kept in the memory of the widget handling an event is open.
281pub(crate) fn is_open_in<Msg>(cx: &mut EventCx<'_, Msg>) -> bool {
282    cx.memory::<ContextMenuMemory>().open
283}
284
285impl<Msg: Clone + 'static> Container<Msg> for ContextMenu<Msg> {
286    fn set_children(&mut self, children: Vec<Node<Msg>>) {
287        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
288        column.layout.width = Length::Fill(1);
289        column.layout.height = Length::Fill(1);
290        self.body = vec![column];
291    }
292}
293
294impl<Msg: Clone + 'static> Widget<Msg> for ContextMenu<Msg> {
295    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
296        self.body.first().map_or(Size::default(), |body| cx.measure_child(body, available))
297    }
298
299    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
300        // Plain content still receives the right click; interactive children sit on top.
301        cx.register_hit(area);
302        if let Some(body) = self.body.first() {
303            cx.paint_child(body, area);
304        }
305        if cx.memory::<ContextMenuMemory>().open {
306            cx.request_overlay(area);
307        }
308    }
309
310    fn paint_overlay(&self, cx: &mut PaintCx<'_>, _area: Rect) {
311        let screen = cx.clip();
312        // Only the menu rows take presses (`menu::paint` registers them); the runtime closes the
313        // menu on a press anywhere else and passes that press on.
314        self.follow_pointer(cx);
315        let (anchor, levels, opened_at) = {
316            let memory = cx.memory::<ContextMenuMemory>();
317            (memory.anchor, memory.levels.clone(), memory.opened_at.clone())
318        };
319        let enter = cx.env().theme().motion().enter;
320        let mut rects = Vec::new();
321        let mut parent_row = anchor;
322        for depth in 0..levels.len() {
323            let items = self.level(&levels, depth);
324            if items.is_empty() {
325                break;
326            }
327            let preferred = if depth == 0 { Placement::Below } else { Placement::Right };
328            let (full, side) = placement::place(parent_row, menu::size(cx, items), screen, preferred);
329            let started = opened_at.get(depth).copied().unwrap_or_default();
330            let progress = cx.progress_since(started, enter, Easing::EaseOut);
331            // Submenus beside their row unfold downwards too.
332            let shown =
333                placement::unfold(full, if side == Placement::Above { side } else { Placement::Below }, progress);
334            menu::paint(cx, items, full, shown, levels[depth]);
335            rects.push(full);
336            let Some(row) = levels[depth] else {
337                break;
338            };
339            parent_row = full.row(clamp_u16(i32::try_from(row).unwrap_or(i32::MAX)));
340        }
341        cx.memory::<ContextMenuMemory>().rects = rects;
342    }
343
344    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
345        let open = cx.memory::<ContextMenuMemory>().open;
346        match event {
347            Event::PointerOutside => {
348                Self::close(cx);
349                true
350            }
351            Event::Key(key) if open => self.key(cx, key),
352            Event::Key(key) => {
353                if !is_menu_key(key) || self.items.is_empty() {
354                    return false;
355                }
356                let area = cx.area();
357                let anchor =
358                    cx.focused_area().filter(|rect| !rect.is_empty()).unwrap_or(Rect::new(area.x, area.y, 1, 1));
359                self.open(cx, anchor, true);
360                true
361            }
362            Event::Mouse(mouse) => match mouse.kind {
363                MouseKind::Down(button) if open => self.press(cx, mouse.x, mouse.y, button),
364                MouseKind::Down(MouseButton::Right) if !self.items.is_empty() => {
365                    self.open(cx, Rect::new(mouse.x, mouse.y, 1, 1), false);
366                    true
367                }
368                MouseKind::Up(_) | MouseKind::Drag(_) | MouseKind::ScrollUp | MouseKind::ScrollDown => open,
369                _ => false,
370            },
371            Event::Paste(_) => false,
372        }
373    }
374
375    fn children(&self) -> &[Node<Msg>] {
376        &self.body
377    }
378
379    fn children_mut(&mut self) -> &mut [Node<Msg>] {
380        &mut self.body
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::runtime::{App, Command, Harness};
388    use crate::widget::View;
389    use crate::widgets::{Button, Text};
390
391    #[derive(Default)]
392    struct Demo {
393        chosen: Vec<&'static str>,
394        pressed: u32,
395    }
396
397    #[derive(Clone)]
398    enum Msg {
399        Chose(&'static str),
400        Press,
401    }
402
403    impl App for Demo {
404        type Msg = Msg;
405        fn update(&mut self, msg: Msg) -> Command<Msg> {
406            match msg {
407                Msg::Chose(name) => self.chosen.push(name),
408                Msg::Press => self.pressed += 1,
409            }
410            Command::none()
411        }
412        fn view(&self, ui: &mut View<'_, Msg>) {
413            let items = [
414                ContextItem::new("Restart", Msg::Chose("restart")).shortcut("ctrl r"),
415                ContextItem::new("Pause", Msg::Chose("pause")).disabled(true),
416                ContextItem::submenu("Move to", [ContextItem::new("Staging", Msg::Chose("staging"))]),
417                ContextItem::gap(),
418                ContextItem::new("Delete", Msg::Chose("delete")).danger(true),
419            ];
420            ui.column(|ui| {
421                ui.add_with(ContextMenu::new(items), |ui| {
422                    ui.add(Text::new("api-gateway"));
423                    ui.add(Button::new("Logs").on_press(Msg::Press)).id("logs");
424                })
425                .height(Length::Cells(10))
426                .fill_width();
427            })
428            .fill_width();
429        }
430    }
431
432    fn right_click(h: &mut Harness<Demo>, x: i32, y: i32) {
433        h.mouse(MouseKind::Down(MouseButton::Right), x, y);
434        h.mouse(MouseKind::Up(MouseButton::Right), x, y);
435    }
436
437    #[test]
438    fn right_click_opens_at_the_pointer_with_shortcuts_anchored_right() {
439        let mut h = Harness::new(Demo::default(), 40, 12);
440        right_click(&mut h, 3, 0);
441        h.advance(Duration::from_millis(200));
442        let screen = h.screen();
443        let lines: Vec<&str> = screen.lines().collect();
444        // The menu opens one row under the pointer and covers what is below it.
445        assert_eq!(lines[1], "  L  Restart    ctrl r", "{screen}");
446        assert_eq!(lines[2], "     Pause");
447        assert_eq!(lines[3], "     Move to         ▶");
448        assert_eq!(lines[4], "");
449        assert_eq!(lines[5], "     Delete");
450        let theme = h.env().theme();
451        assert_eq!(h.bg(3, 1), theme.color("overlay"));
452        assert_eq!(h.fg(5, 5), theme.color("danger"));
453        assert_eq!(h.fg(5, 2), theme.color("muted"));
454        assert_eq!(h.fg(16, 1), theme.color("muted"));
455    }
456
457    #[test]
458    fn keyboard_skips_disabled_rows_and_gaps_and_chooses() {
459        let mut h = Harness::new(Demo::default(), 40, 12);
460        h.set_reduced_motion(true);
461        right_click(&mut h, 3, 0);
462        h.press("down").press("down");
463        let screen = h.screen();
464        assert!(screen.lines().nth(3).is_some_and(|line| line.starts_with("   ▌  Move to")), "{screen}");
465        h.press("down").press("enter");
466        assert_eq!(h.app().chosen, vec!["delete"]);
467        assert!(!h.screen().contains("Restart"));
468    }
469
470    #[test]
471    fn submenus_open_with_right_and_close_with_left() {
472        let mut h = Harness::new(Demo::default(), 40, 12);
473        h.set_reduced_motion(true);
474        right_click(&mut h, 3, 0);
475        h.press("m").press("right");
476        assert!(h.screen().contains("Staging"), "{}", h.screen());
477        h.press("left");
478        assert!(!h.screen().contains("Staging"));
479        h.press("enter").press("enter");
480        assert_eq!(h.app().chosen, vec!["staging"]);
481    }
482
483    #[test]
484    fn hovering_a_submenu_opens_it_and_clicks_choose() {
485        let mut h = Harness::new(Demo::default(), 40, 12);
486        h.set_reduced_motion(true);
487        right_click(&mut h, 3, 0);
488        h.hover(8, 3);
489        let (x, y) = h.find("Staging").expect("submenu opened on hover");
490        h.click(x, y);
491        assert_eq!(h.app().chosen, vec!["staging"]);
492    }
493
494    #[test]
495    fn outside_press_closes_and_still_reaches_the_button() {
496        let mut h = Harness::new(Demo::default(), 40, 12);
497        h.set_reduced_motion(true);
498        right_click(&mut h, 20, 8);
499        assert!(h.screen().contains("Restart"));
500        h.click_text("Logs");
501        assert_eq!(h.app().pressed, 1, "one press closes the menu and presses the button");
502        assert!(!h.screen().contains("Restart"));
503        assert!(h.app().chosen.is_empty());
504    }
505
506    #[test]
507    fn shift_f10_opens_next_to_the_focused_widget() {
508        let mut h = Harness::new(Demo::default(), 40, 12);
509        h.set_reduced_motion(true);
510        h.press("tab").press("shift+f10");
511        let screen = h.screen();
512        assert!(screen.lines().nth(2).is_some_and(|line| line.starts_with("▌  Restart   ctrl r")), "{screen}");
513        h.press("esc");
514        assert!(!h.screen().contains("Restart"));
515        assert!(h.is_focused("logs"));
516    }
517
518    #[test]
519    fn flips_above_near_the_bottom() {
520        let mut h = Harness::new(Demo::default(), 40, 12);
521        h.set_reduced_motion(true);
522        right_click(&mut h, 3, 9);
523        let (_, y) = h.find("Delete").expect("open");
524        assert_eq!(y, 8);
525    }
526}