Skip to main content

agg_gui/widgets/menu/
state.rs

1//! Open menu state and event-side behavior.
2//!
3//! The state keeps only interaction data. Item trees stay in the model so
4//! callers can rebuild or reuse menus without carrying transient hover state.
5
6use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
7use crate::geometry::{Point, Size};
8
9use super::geometry::{hit_test, item_at_path, stack_layout, MenuHit, PopupLayout};
10use super::model::{MenuEntry, MenuSelection};
11
12/// Wall-clock window during which a touch event still classifies follow-up
13/// mouse events as touch-synthesised.  Mirrors the constant in the menu
14/// widget; duplicated here so this module stays standalone-testable
15/// instead of pulling in the widget impl.
16const TOUCH_SYNTH_WINDOW_MS: u128 = 50;
17
18fn is_touch_synthesized() -> bool {
19    crate::touch_state::last_touch_event_age()
20        .map(|d| d.as_millis() < TOUCH_SYNTH_WINDOW_MS)
21        .unwrap_or(false)
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum MenuAnchorKind {
26    Context,
27    /// Top menu bar — anchor is the bar item's BOTTOM edge; popup
28    /// opens DOWNWARD (extending toward smaller y in Y-up).
29    Bar,
30    /// Bottom menu bar — anchor is the bar item's TOP edge; popup
31    /// opens UPWARD (extending toward larger y in Y-up). Used by
32    /// callers that position the menu bar across the bottom of
33    /// the viewport, where opening downward would clip the popup
34    /// against the viewport floor.
35    BottomBar,
36}
37
38#[derive(Clone, Debug)]
39pub struct PopupMenuState {
40    pub anchor: Point,
41    pub anchor_kind: MenuAnchorKind,
42    pub open: bool,
43    pub open_path: Vec<usize>,
44    pub hover_path: Option<Vec<usize>>,
45    suppress_next_mouse_up: bool,
46    activate_on_mouse_up: bool,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum MenuResponse {
51    None,
52    Action(String),
53    Closed,
54}
55
56impl Default for PopupMenuState {
57    fn default() -> Self {
58        Self {
59            anchor: Point::ORIGIN,
60            anchor_kind: MenuAnchorKind::Context,
61            open: false,
62            open_path: Vec::new(),
63            hover_path: None,
64            suppress_next_mouse_up: false,
65            activate_on_mouse_up: false,
66        }
67    }
68}
69
70impl PopupMenuState {
71    pub fn open_at(&mut self, anchor: Point, anchor_kind: MenuAnchorKind) {
72        self.anchor = anchor;
73        self.anchor_kind = anchor_kind;
74        self.open = true;
75        self.open_path.clear();
76        self.hover_path = None;
77        self.suppress_next_mouse_up = false;
78        self.activate_on_mouse_up = false;
79    }
80
81    pub fn close(&mut self) {
82        self.open = false;
83        self.open_path.clear();
84        self.hover_path = None;
85        self.activate_on_mouse_up = false;
86    }
87
88    pub fn arm_mouse_up_activation(&mut self) {
89        self.activate_on_mouse_up = true;
90    }
91
92    pub fn is_mouse_up_activation_armed(&self) -> bool {
93        self.activate_on_mouse_up
94    }
95
96    pub fn handle_shortcut(
97        &mut self,
98        items: &mut [MenuEntry],
99        key: &Key,
100        modifiers: Modifiers,
101    ) -> MenuResponse {
102        let Some(path) = shortcut_path(items, key, modifiers) else {
103            return MenuResponse::None;
104        };
105        let Some(item) = item_at_path(items, &path) else {
106            return MenuResponse::None;
107        };
108        let Some(action) = item.action.clone() else {
109            return MenuResponse::None;
110        };
111        let close_on_activate = item.close_on_activate;
112        let (_, response) = self.activate_action(items, &path, action, close_on_activate, false);
113        response
114    }
115
116    pub fn should_suppress_mouse_up(&self) -> bool {
117        self.suppress_next_mouse_up
118    }
119
120    pub fn take_suppress_mouse_up(&mut self) -> bool {
121        let suppress = self.suppress_next_mouse_up;
122        self.suppress_next_mouse_up = false;
123        suppress
124    }
125
126    pub fn layouts(&self, items: &[MenuEntry], viewport: Size) -> Vec<PopupLayout> {
127        if self.open {
128            stack_layout(
129                items,
130                self.anchor,
131                self.anchor_kind,
132                &self.open_path,
133                viewport,
134            )
135        } else {
136            Vec::new()
137        }
138    }
139
140    pub fn handle_event(
141        &mut self,
142        items: &mut [MenuEntry],
143        event: &Event,
144        viewport: Size,
145    ) -> (EventResult, MenuResponse) {
146        if !self.open {
147            return (EventResult::Ignored, MenuResponse::None);
148        }
149        match event {
150            Event::MouseMove { pos } => {
151                let changed = self.update_hover(items, *pos, viewport);
152                if changed {
153                    crate::animation::request_draw_without_invalidation();
154                }
155                (EventResult::Consumed, MenuResponse::None)
156            }
157            Event::MouseDown {
158                pos,
159                button: MouseButton::Left,
160                ..
161            } => self.handle_left_down(items, *pos, viewport),
162            // Any non-left press dismisses — the desktop convention.
163            // Right-clicking with a menu open closes it instead of
164            // leaving it hanging over whatever context menu that press
165            // is about to raise. Consumed so the press only dismisses:
166            // it must not also activate the item underneath it.
167            Event::MouseDown { .. } => {
168                self.close();
169                crate::animation::request_draw();
170                (EventResult::Consumed, MenuResponse::Closed)
171            }
172            Event::MouseUp {
173                pos,
174                button: MouseButton::Left,
175                ..
176            } if self.activate_on_mouse_up => {
177                self.activate_on_mouse_up = false;
178                self.handle_release_activation(items, *pos, viewport)
179            }
180            Event::MouseUp {
181                button: MouseButton::Left,
182                ..
183            } if self.take_suppress_mouse_up() => (EventResult::Consumed, MenuResponse::None),
184            Event::KeyDown { key, modifiers } => {
185                let response = self.handle_shortcut(items, key, *modifiers);
186                if response != MenuResponse::None {
187                    (EventResult::Consumed, response)
188                } else {
189                    self.handle_key(items, key.clone())
190                }
191            }
192            _ => (EventResult::Ignored, MenuResponse::None),
193        }
194    }
195
196    pub fn update_hover(&mut self, items: &[MenuEntry], pos: Point, viewport: Size) -> bool {
197        // Touch has no hover concept: a tap synthesises a MouseMove at the tap
198        // point right before the MouseDown.  Doing hover work here — especially
199        // OPENING a submenu via `open_path` — means the follow-up MouseDown
200        // hit-tests against the just-opened submenu and, on a narrow viewport
201        // where the submenu overlaps its parent, lands on (and activates) the
202        // submenu's first child instead of opening the submenu.  On touch,
203        // submenus open only on the explicit tap in `handle_left_down`; leave
204        // `hover_path`/`open_path` untouched here (clear any stale hover).
205        if is_touch_synthesized() {
206            return self.set_hover_path(None);
207        }
208        let layouts = self.layouts(items, viewport);
209        let next_hover = match hit_test(&layouts, pos) {
210            Some(MenuHit::Item(path)) => {
211                if let Some(item) = item_at_path(items, &path) {
212                    if !item.enabled {
213                        if !self.open_path.starts_with(&path) {
214                            self.open_path.truncate(path.len().saturating_sub(1));
215                        }
216                        return self.set_hover_path(None);
217                    }
218                    if item.enabled && item.has_submenu() {
219                        self.open_path = path.clone();
220                    } else if !self.open_path.starts_with(&path) {
221                        self.open_path.truncate(path.len().saturating_sub(1));
222                    }
223                }
224                Some(path)
225            }
226            _ => None,
227        };
228        // (Touch is handled by the early return above — only desktop hover
229        // reaches here.)
230        if self.hover_path != next_hover {
231            self.hover_path = next_hover;
232            true
233        } else {
234            false
235        }
236    }
237
238    fn set_hover_path(&mut self, hover_path: Option<Vec<usize>>) -> bool {
239        if self.hover_path != hover_path {
240            self.hover_path = hover_path;
241            true
242        } else {
243            false
244        }
245    }
246
247    fn handle_left_down(
248        &mut self,
249        items: &mut [MenuEntry],
250        pos: Point,
251        viewport: Size,
252    ) -> (EventResult, MenuResponse) {
253        let layouts = self.layouts(items, viewport);
254        match hit_test(&layouts, pos) {
255            Some(MenuHit::Item(path)) => {
256                let Some(item) = item_at_path(items, &path) else {
257                    return (EventResult::Consumed, MenuResponse::None);
258                };
259                let enabled = item.enabled;
260                let has_submenu = item.has_submenu();
261                let action = item.action.clone();
262                let close_on_activate = item.close_on_activate;
263                if !enabled {
264                    self.hover_path = None;
265                    return (EventResult::Consumed, MenuResponse::None);
266                }
267                self.hover_path = Some(path.clone());
268                if has_submenu {
269                    self.open_path = path;
270                    crate::animation::request_draw();
271                    (EventResult::Consumed, MenuResponse::None)
272                } else if let Some(action) = action {
273                    self.activate_action(items, &path, action, close_on_activate, true)
274                } else {
275                    (EventResult::Consumed, MenuResponse::None)
276                }
277            }
278            Some(MenuHit::Panel) => (EventResult::Consumed, MenuResponse::None),
279            None => {
280                self.close();
281                self.suppress_next_mouse_up = true;
282                crate::animation::request_draw();
283                (EventResult::Consumed, MenuResponse::Closed)
284            }
285        }
286    }
287
288    fn handle_release_activation(
289        &mut self,
290        items: &mut [MenuEntry],
291        pos: Point,
292        viewport: Size,
293    ) -> (EventResult, MenuResponse) {
294        let layouts = self.layouts(items, viewport);
295        match hit_test(&layouts, pos) {
296            Some(MenuHit::Item(path)) => {
297                self.hover_path = Some(path.clone());
298                let Some(item) = item_at_path(items, &path) else {
299                    return (EventResult::Consumed, MenuResponse::None);
300                };
301                let enabled = item.enabled;
302                let has_submenu = item.has_submenu();
303                let action = item.action.clone();
304                let close_on_activate = item.close_on_activate;
305                if !enabled || has_submenu {
306                    return (EventResult::Consumed, MenuResponse::None);
307                }
308                if let Some(action) = action {
309                    self.activate_action(items, &path, action, close_on_activate, false)
310                } else {
311                    (EventResult::Consumed, MenuResponse::None)
312                }
313            }
314            Some(MenuHit::Panel) | None => (EventResult::Consumed, MenuResponse::None),
315        }
316    }
317
318    fn activate_action(
319        &mut self,
320        items: &mut [MenuEntry],
321        path: &[usize],
322        action: String,
323        close_on_activate: bool,
324        suppress_mouse_up: bool,
325    ) -> (EventResult, MenuResponse) {
326        toggle_selection_at_path(items, path);
327        if close_on_activate {
328            self.close();
329            self.suppress_next_mouse_up = suppress_mouse_up;
330        }
331        crate::animation::request_draw();
332        (EventResult::Consumed, MenuResponse::Action(action))
333    }
334
335    fn handle_key(&mut self, items: &mut [MenuEntry], key: Key) -> (EventResult, MenuResponse) {
336        match key {
337            Key::Escape => {
338                self.close();
339                crate::animation::request_draw();
340                (EventResult::Consumed, MenuResponse::Closed)
341            }
342            Key::ArrowDown => {
343                self.step_hover(items, 1);
344                (EventResult::Consumed, MenuResponse::None)
345            }
346            Key::ArrowUp => {
347                self.step_hover(items, -1);
348                (EventResult::Consumed, MenuResponse::None)
349            }
350            Key::ArrowRight => {
351                if let Some(path) = self.hover_path.clone() {
352                    if item_at_path(items, &path).is_some_and(|item| item.has_submenu()) {
353                        self.open_path = path;
354                        crate::animation::request_draw();
355                    }
356                }
357                (EventResult::Consumed, MenuResponse::None)
358            }
359            Key::ArrowLeft => {
360                self.open_path.pop();
361                self.hover_path = self.open_path.last().map(|_| self.open_path.clone());
362                crate::animation::request_draw();
363                (EventResult::Consumed, MenuResponse::None)
364            }
365            Key::Enter | Key::Char(' ') => {
366                if let Some(path) = self.hover_path.clone() {
367                    if let Some(item) = item_at_path(items, &path) {
368                        let enabled = item.enabled;
369                        let has_submenu = item.has_submenu();
370                        let action = item.action.clone();
371                        let close_on_activate = item.close_on_activate;
372                        if enabled && has_submenu {
373                            self.open_path = path;
374                        } else if enabled {
375                            if let Some(action) = action {
376                                return self.activate_action(
377                                    items,
378                                    &path,
379                                    action,
380                                    close_on_activate,
381                                    false,
382                                );
383                            }
384                        }
385                    }
386                }
387                (EventResult::Consumed, MenuResponse::None)
388            }
389            _ => (EventResult::Ignored, MenuResponse::None),
390        }
391    }
392
393    fn step_hover(&mut self, items: &[MenuEntry], delta: isize) {
394        let level_items = items_at_path(items, &self.open_path).unwrap_or(items);
395        let enabled: Vec<usize> = level_items
396            .iter()
397            .enumerate()
398            .filter_map(|(idx, entry)| match entry {
399                MenuEntry::Item(item) if item.enabled => Some(idx),
400                _ => None,
401            })
402            .collect();
403        if enabled.is_empty() {
404            return;
405        }
406        let current = self
407            .hover_path
408            .as_ref()
409            .and_then(|path| path.last().copied())
410            .and_then(|idx| enabled.iter().position(|candidate| *candidate == idx));
411        let base = current
412            .map(|idx| idx as isize)
413            .unwrap_or(if delta > 0 { -1 } else { 0 });
414        let next = (base + delta).rem_euclid(enabled.len() as isize) as usize;
415        let mut path = self.open_path.clone();
416        path.push(enabled[next]);
417        self.hover_path = Some(path);
418        crate::animation::request_draw();
419    }
420}
421
422fn items_at_path<'a>(items: &'a [MenuEntry], path: &[usize]) -> Option<&'a [MenuEntry]> {
423    let mut current = items;
424    for &idx in path {
425        current = &item_at_path(current, &[idx])?.submenu;
426    }
427    Some(current)
428}
429
430fn toggle_selection_at_path(items: &mut [MenuEntry], path: &[usize]) {
431    let Some(selection) = item_at_path(items, path).map(|item| item.selection) else {
432        return;
433    };
434    match selection {
435        MenuSelection::Check { selected } => {
436            if let Some(item) = item_at_path_mut(items, path) {
437                item.selection = MenuSelection::Check {
438                    selected: !selected,
439                };
440            }
441        }
442        MenuSelection::Radio { .. } => {
443            let Some((&idx, parent_path)) = path.split_last() else {
444                return;
445            };
446            let Some(parent) = entries_at_path_mut(items, parent_path) else {
447                return;
448            };
449            for entry in parent.iter_mut() {
450                if let MenuEntry::Item(item) = entry {
451                    if matches!(item.selection, MenuSelection::Radio { .. }) {
452                        item.selection = MenuSelection::Radio { selected: false };
453                    }
454                }
455            }
456            if let Some(MenuEntry::Item(item)) = parent.get_mut(idx) {
457                item.selection = MenuSelection::Radio { selected: true };
458            }
459        }
460        MenuSelection::None => {}
461    }
462}
463
464fn item_at_path_mut<'a>(
465    items: &'a mut [MenuEntry],
466    path: &[usize],
467) -> Option<&'a mut super::model::MenuItem> {
468    let (&idx, rest) = path.split_first()?;
469    let entry = items.get_mut(idx)?;
470    match entry {
471        MenuEntry::Item(item) => {
472            if rest.is_empty() {
473                Some(item)
474            } else {
475                item_at_path_mut(&mut item.submenu, rest)
476            }
477        }
478        MenuEntry::Separator => None,
479    }
480}
481
482fn entries_at_path_mut<'a>(
483    items: &'a mut [MenuEntry],
484    path: &[usize],
485) -> Option<&'a mut [MenuEntry]> {
486    if path.is_empty() {
487        return Some(items);
488    }
489    let (&idx, rest) = path.split_first()?;
490    match items.get_mut(idx)? {
491        MenuEntry::Item(item) => entries_at_path_mut(&mut item.submenu, rest),
492        MenuEntry::Separator => None,
493    }
494}
495
496fn shortcut_path(items: &[MenuEntry], key: &Key, modifiers: Modifiers) -> Option<Vec<usize>> {
497    for (idx, entry) in items.iter().enumerate() {
498        let MenuEntry::Item(item) = entry else {
499            continue;
500        };
501        if item.enabled
502            && item
503                .accelerator
504                .is_some_and(|accelerator| accelerator.matches(key, modifiers))
505            && item.action.is_some()
506        {
507            return Some(vec![idx]);
508        }
509        if let Some(mut path) = shortcut_path(&item.submenu, key, modifiers) {
510            path.insert(0, idx);
511            return Some(path);
512        }
513    }
514    None
515}