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            Event::MouseUp {
163                pos,
164                button: MouseButton::Left,
165                ..
166            } if self.activate_on_mouse_up => {
167                self.activate_on_mouse_up = false;
168                self.handle_release_activation(items, *pos, viewport)
169            }
170            Event::MouseUp {
171                button: MouseButton::Left,
172                ..
173            } if self.take_suppress_mouse_up() => (EventResult::Consumed, MenuResponse::None),
174            Event::KeyDown { key, modifiers } => {
175                let response = self.handle_shortcut(items, key, *modifiers);
176                if response != MenuResponse::None {
177                    (EventResult::Consumed, response)
178                } else {
179                    self.handle_key(items, key.clone())
180                }
181            }
182            _ => (EventResult::Ignored, MenuResponse::None),
183        }
184    }
185
186    pub fn update_hover(&mut self, items: &[MenuEntry], pos: Point, viewport: Size) -> bool {
187        // Touch has no hover concept: a tap synthesises a MouseMove at the tap
188        // point right before the MouseDown.  Doing hover work here — especially
189        // OPENING a submenu via `open_path` — means the follow-up MouseDown
190        // hit-tests against the just-opened submenu and, on a narrow viewport
191        // where the submenu overlaps its parent, lands on (and activates) the
192        // submenu's first child instead of opening the submenu.  On touch,
193        // submenus open only on the explicit tap in `handle_left_down`; leave
194        // `hover_path`/`open_path` untouched here (clear any stale hover).
195        if is_touch_synthesized() {
196            return self.set_hover_path(None);
197        }
198        let layouts = self.layouts(items, viewport);
199        let next_hover = match hit_test(&layouts, pos) {
200            Some(MenuHit::Item(path)) => {
201                if let Some(item) = item_at_path(items, &path) {
202                    if !item.enabled {
203                        if !self.open_path.starts_with(&path) {
204                            self.open_path.truncate(path.len().saturating_sub(1));
205                        }
206                        return self.set_hover_path(None);
207                    }
208                    if item.enabled && item.has_submenu() {
209                        self.open_path = path.clone();
210                    } else if !self.open_path.starts_with(&path) {
211                        self.open_path.truncate(path.len().saturating_sub(1));
212                    }
213                }
214                Some(path)
215            }
216            _ => None,
217        };
218        // (Touch is handled by the early return above — only desktop hover
219        // reaches here.)
220        if self.hover_path != next_hover {
221            self.hover_path = next_hover;
222            true
223        } else {
224            false
225        }
226    }
227
228    fn set_hover_path(&mut self, hover_path: Option<Vec<usize>>) -> bool {
229        if self.hover_path != hover_path {
230            self.hover_path = hover_path;
231            true
232        } else {
233            false
234        }
235    }
236
237    fn handle_left_down(
238        &mut self,
239        items: &mut [MenuEntry],
240        pos: Point,
241        viewport: Size,
242    ) -> (EventResult, MenuResponse) {
243        let layouts = self.layouts(items, viewport);
244        match hit_test(&layouts, pos) {
245            Some(MenuHit::Item(path)) => {
246                let Some(item) = item_at_path(items, &path) else {
247                    return (EventResult::Consumed, MenuResponse::None);
248                };
249                let enabled = item.enabled;
250                let has_submenu = item.has_submenu();
251                let action = item.action.clone();
252                let close_on_activate = item.close_on_activate;
253                if !enabled {
254                    self.hover_path = None;
255                    return (EventResult::Consumed, MenuResponse::None);
256                }
257                self.hover_path = Some(path.clone());
258                if has_submenu {
259                    self.open_path = path;
260                    crate::animation::request_draw();
261                    (EventResult::Consumed, MenuResponse::None)
262                } else if let Some(action) = action {
263                    self.activate_action(items, &path, action, close_on_activate, true)
264                } else {
265                    (EventResult::Consumed, MenuResponse::None)
266                }
267            }
268            Some(MenuHit::Panel) => (EventResult::Consumed, MenuResponse::None),
269            None => {
270                self.close();
271                self.suppress_next_mouse_up = true;
272                crate::animation::request_draw();
273                (EventResult::Consumed, MenuResponse::Closed)
274            }
275        }
276    }
277
278    fn handle_release_activation(
279        &mut self,
280        items: &mut [MenuEntry],
281        pos: Point,
282        viewport: Size,
283    ) -> (EventResult, MenuResponse) {
284        let layouts = self.layouts(items, viewport);
285        match hit_test(&layouts, pos) {
286            Some(MenuHit::Item(path)) => {
287                self.hover_path = Some(path.clone());
288                let Some(item) = item_at_path(items, &path) else {
289                    return (EventResult::Consumed, MenuResponse::None);
290                };
291                let enabled = item.enabled;
292                let has_submenu = item.has_submenu();
293                let action = item.action.clone();
294                let close_on_activate = item.close_on_activate;
295                if !enabled || has_submenu {
296                    return (EventResult::Consumed, MenuResponse::None);
297                }
298                if let Some(action) = action {
299                    self.activate_action(items, &path, action, close_on_activate, false)
300                } else {
301                    (EventResult::Consumed, MenuResponse::None)
302                }
303            }
304            Some(MenuHit::Panel) | None => (EventResult::Consumed, MenuResponse::None),
305        }
306    }
307
308    fn activate_action(
309        &mut self,
310        items: &mut [MenuEntry],
311        path: &[usize],
312        action: String,
313        close_on_activate: bool,
314        suppress_mouse_up: bool,
315    ) -> (EventResult, MenuResponse) {
316        toggle_selection_at_path(items, path);
317        if close_on_activate {
318            self.close();
319            self.suppress_next_mouse_up = suppress_mouse_up;
320        }
321        crate::animation::request_draw();
322        (EventResult::Consumed, MenuResponse::Action(action))
323    }
324
325    fn handle_key(&mut self, items: &mut [MenuEntry], key: Key) -> (EventResult, MenuResponse) {
326        match key {
327            Key::Escape => {
328                self.close();
329                crate::animation::request_draw();
330                (EventResult::Consumed, MenuResponse::Closed)
331            }
332            Key::ArrowDown => {
333                self.step_hover(items, 1);
334                (EventResult::Consumed, MenuResponse::None)
335            }
336            Key::ArrowUp => {
337                self.step_hover(items, -1);
338                (EventResult::Consumed, MenuResponse::None)
339            }
340            Key::ArrowRight => {
341                if let Some(path) = self.hover_path.clone() {
342                    if item_at_path(items, &path).is_some_and(|item| item.has_submenu()) {
343                        self.open_path = path;
344                        crate::animation::request_draw();
345                    }
346                }
347                (EventResult::Consumed, MenuResponse::None)
348            }
349            Key::ArrowLeft => {
350                self.open_path.pop();
351                self.hover_path = self.open_path.last().map(|_| self.open_path.clone());
352                crate::animation::request_draw();
353                (EventResult::Consumed, MenuResponse::None)
354            }
355            Key::Enter | Key::Char(' ') => {
356                if let Some(path) = self.hover_path.clone() {
357                    if let Some(item) = item_at_path(items, &path) {
358                        let enabled = item.enabled;
359                        let has_submenu = item.has_submenu();
360                        let action = item.action.clone();
361                        let close_on_activate = item.close_on_activate;
362                        if enabled && has_submenu {
363                            self.open_path = path;
364                        } else if enabled {
365                            if let Some(action) = action {
366                                return self.activate_action(
367                                    items,
368                                    &path,
369                                    action,
370                                    close_on_activate,
371                                    false,
372                                );
373                            }
374                        }
375                    }
376                }
377                (EventResult::Consumed, MenuResponse::None)
378            }
379            _ => (EventResult::Ignored, MenuResponse::None),
380        }
381    }
382
383    fn step_hover(&mut self, items: &[MenuEntry], delta: isize) {
384        let level_items = items_at_path(items, &self.open_path).unwrap_or(items);
385        let enabled: Vec<usize> = level_items
386            .iter()
387            .enumerate()
388            .filter_map(|(idx, entry)| match entry {
389                MenuEntry::Item(item) if item.enabled => Some(idx),
390                _ => None,
391            })
392            .collect();
393        if enabled.is_empty() {
394            return;
395        }
396        let current = self
397            .hover_path
398            .as_ref()
399            .and_then(|path| path.last().copied())
400            .and_then(|idx| enabled.iter().position(|candidate| *candidate == idx));
401        let base = current
402            .map(|idx| idx as isize)
403            .unwrap_or(if delta > 0 { -1 } else { 0 });
404        let next = (base + delta).rem_euclid(enabled.len() as isize) as usize;
405        let mut path = self.open_path.clone();
406        path.push(enabled[next]);
407        self.hover_path = Some(path);
408        crate::animation::request_draw();
409    }
410}
411
412fn items_at_path<'a>(items: &'a [MenuEntry], path: &[usize]) -> Option<&'a [MenuEntry]> {
413    let mut current = items;
414    for &idx in path {
415        current = &item_at_path(current, &[idx])?.submenu;
416    }
417    Some(current)
418}
419
420fn toggle_selection_at_path(items: &mut [MenuEntry], path: &[usize]) {
421    let Some(selection) = item_at_path(items, path).map(|item| item.selection) else {
422        return;
423    };
424    match selection {
425        MenuSelection::Check { selected } => {
426            if let Some(item) = item_at_path_mut(items, path) {
427                item.selection = MenuSelection::Check {
428                    selected: !selected,
429                };
430            }
431        }
432        MenuSelection::Radio { .. } => {
433            let Some((&idx, parent_path)) = path.split_last() else {
434                return;
435            };
436            let Some(parent) = entries_at_path_mut(items, parent_path) else {
437                return;
438            };
439            for entry in parent.iter_mut() {
440                if let MenuEntry::Item(item) = entry {
441                    if matches!(item.selection, MenuSelection::Radio { .. }) {
442                        item.selection = MenuSelection::Radio { selected: false };
443                    }
444                }
445            }
446            if let Some(MenuEntry::Item(item)) = parent.get_mut(idx) {
447                item.selection = MenuSelection::Radio { selected: true };
448            }
449        }
450        MenuSelection::None => {}
451    }
452}
453
454fn item_at_path_mut<'a>(
455    items: &'a mut [MenuEntry],
456    path: &[usize],
457) -> Option<&'a mut super::model::MenuItem> {
458    let (&idx, rest) = path.split_first()?;
459    let entry = items.get_mut(idx)?;
460    match entry {
461        MenuEntry::Item(item) => {
462            if rest.is_empty() {
463                Some(item)
464            } else {
465                item_at_path_mut(&mut item.submenu, rest)
466            }
467        }
468        MenuEntry::Separator => None,
469    }
470}
471
472fn entries_at_path_mut<'a>(
473    items: &'a mut [MenuEntry],
474    path: &[usize],
475) -> Option<&'a mut [MenuEntry]> {
476    if path.is_empty() {
477        return Some(items);
478    }
479    let (&idx, rest) = path.split_first()?;
480    match items.get_mut(idx)? {
481        MenuEntry::Item(item) => entries_at_path_mut(&mut item.submenu, rest),
482        MenuEntry::Separator => None,
483    }
484}
485
486fn shortcut_path(items: &[MenuEntry], key: &Key, modifiers: Modifiers) -> Option<Vec<usize>> {
487    for (idx, entry) in items.iter().enumerate() {
488        let MenuEntry::Item(item) = entry else {
489            continue;
490        };
491        if item.enabled
492            && item
493                .accelerator
494                .is_some_and(|accelerator| accelerator.matches(key, modifiers))
495            && item.action.is_some()
496        {
497            return Some(vec![idx]);
498        }
499        if let Some(mut path) = shortcut_path(&item.submenu, key, modifiers) {
500            path.insert(0, idx);
501            return Some(path);
502        }
503    }
504    None
505}