1use 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
13pub 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 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 #[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 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 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 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 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 fn press(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32, button: MouseButton) -> bool {
190 let (rects, levels) = open_rects(cx.memory::<ContextMenuMemory>());
191 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 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 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
253pub(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
259pub(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
266fn 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
274pub(crate) fn is_open(cx: &mut PaintCx<'_>) -> bool {
277 cx.memory::<ContextMenuMemory>().open
278}
279
280pub(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 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 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 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 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}