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    Bar,
28}
29
30#[derive(Clone, Debug)]
31pub struct PopupMenuState {
32    pub anchor: Point,
33    pub anchor_kind: MenuAnchorKind,
34    pub open: bool,
35    pub open_path: Vec<usize>,
36    pub hover_path: Option<Vec<usize>>,
37    suppress_next_mouse_up: bool,
38    activate_on_mouse_up: bool,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum MenuResponse {
43    None,
44    Action(String),
45    Closed,
46}
47
48impl Default for PopupMenuState {
49    fn default() -> Self {
50        Self {
51            anchor: Point::ORIGIN,
52            anchor_kind: MenuAnchorKind::Context,
53            open: false,
54            open_path: Vec::new(),
55            hover_path: None,
56            suppress_next_mouse_up: false,
57            activate_on_mouse_up: false,
58        }
59    }
60}
61
62impl PopupMenuState {
63    pub fn open_at(&mut self, anchor: Point, anchor_kind: MenuAnchorKind) {
64        self.anchor = anchor;
65        self.anchor_kind = anchor_kind;
66        self.open = true;
67        self.open_path.clear();
68        self.hover_path = None;
69        self.suppress_next_mouse_up = false;
70        self.activate_on_mouse_up = false;
71    }
72
73    pub fn close(&mut self) {
74        self.open = false;
75        self.open_path.clear();
76        self.hover_path = None;
77        self.activate_on_mouse_up = false;
78    }
79
80    pub fn arm_mouse_up_activation(&mut self) {
81        self.activate_on_mouse_up = true;
82    }
83
84    pub fn is_mouse_up_activation_armed(&self) -> bool {
85        self.activate_on_mouse_up
86    }
87
88    pub fn handle_shortcut(
89        &mut self,
90        items: &mut [MenuEntry],
91        key: &Key,
92        modifiers: Modifiers,
93    ) -> MenuResponse {
94        let Some(path) = shortcut_path(items, key, modifiers) else {
95            return MenuResponse::None;
96        };
97        let Some(item) = item_at_path(items, &path) else {
98            return MenuResponse::None;
99        };
100        let Some(action) = item.action.clone() else {
101            return MenuResponse::None;
102        };
103        let close_on_activate = item.close_on_activate;
104        let (_, response) = self.activate_action(items, &path, action, close_on_activate, false);
105        response
106    }
107
108    pub fn should_suppress_mouse_up(&self) -> bool {
109        self.suppress_next_mouse_up
110    }
111
112    pub fn take_suppress_mouse_up(&mut self) -> bool {
113        let suppress = self.suppress_next_mouse_up;
114        self.suppress_next_mouse_up = false;
115        suppress
116    }
117
118    pub fn layouts(&self, items: &[MenuEntry], viewport: Size) -> Vec<PopupLayout> {
119        if self.open {
120            stack_layout(
121                items,
122                self.anchor,
123                self.anchor_kind,
124                &self.open_path,
125                viewport,
126            )
127        } else {
128            Vec::new()
129        }
130    }
131
132    pub fn handle_event(
133        &mut self,
134        items: &mut [MenuEntry],
135        event: &Event,
136        viewport: Size,
137    ) -> (EventResult, MenuResponse) {
138        if !self.open {
139            return (EventResult::Ignored, MenuResponse::None);
140        }
141        match event {
142            Event::MouseMove { pos } => {
143                let changed = self.update_hover(items, *pos, viewport);
144                if changed {
145                    crate::animation::request_draw_without_invalidation();
146                }
147                (EventResult::Consumed, MenuResponse::None)
148            }
149            Event::MouseDown {
150                pos,
151                button: MouseButton::Left,
152                ..
153            } => self.handle_left_down(items, *pos, viewport),
154            Event::MouseUp {
155                pos,
156                button: MouseButton::Left,
157                ..
158            } if self.activate_on_mouse_up => {
159                self.activate_on_mouse_up = false;
160                self.handle_release_activation(items, *pos, viewport)
161            }
162            Event::MouseUp {
163                button: MouseButton::Left,
164                ..
165            } if self.take_suppress_mouse_up() => (EventResult::Consumed, MenuResponse::None),
166            Event::KeyDown { key, modifiers } => {
167                let response = self.handle_shortcut(items, key, *modifiers);
168                if response != MenuResponse::None {
169                    (EventResult::Consumed, response)
170                } else {
171                    self.handle_key(items, key.clone())
172                }
173            }
174            _ => (EventResult::Ignored, MenuResponse::None),
175        }
176    }
177
178    pub fn update_hover(&mut self, items: &[MenuEntry], pos: Point, viewport: Size) -> bool {
179        let layouts = self.layouts(items, viewport);
180        let next_hover = match hit_test(&layouts, pos) {
181            Some(MenuHit::Item(path)) => {
182                if let Some(item) = item_at_path(items, &path) {
183                    if !item.enabled {
184                        if !self.open_path.starts_with(&path) {
185                            self.open_path.truncate(path.len().saturating_sub(1));
186                        }
187                        return self.set_hover_path(None);
188                    }
189                    if item.enabled && item.has_submenu() {
190                        self.open_path = path.clone();
191                    } else if !self.open_path.starts_with(&path) {
192                        self.open_path.truncate(path.len().saturating_sub(1));
193                    }
194                }
195                Some(path)
196            }
197            _ => None,
198        };
199        // Touch-synth MouseMove arrives during a tap; treat hover as None
200        // so the dropped finger doesn't leave a hover panel behind on the
201        // popup row after the tap closes the menu.
202        let next_hover = if is_touch_synthesized() {
203            None
204        } else {
205            next_hover
206        };
207        if self.hover_path != next_hover {
208            self.hover_path = next_hover;
209            true
210        } else {
211            false
212        }
213    }
214
215    fn set_hover_path(&mut self, hover_path: Option<Vec<usize>>) -> bool {
216        if self.hover_path != hover_path {
217            self.hover_path = hover_path;
218            true
219        } else {
220            false
221        }
222    }
223
224    fn handle_left_down(
225        &mut self,
226        items: &mut [MenuEntry],
227        pos: Point,
228        viewport: Size,
229    ) -> (EventResult, MenuResponse) {
230        let layouts = self.layouts(items, viewport);
231        match hit_test(&layouts, pos) {
232            Some(MenuHit::Item(path)) => {
233                let Some(item) = item_at_path(items, &path) else {
234                    return (EventResult::Consumed, MenuResponse::None);
235                };
236                let enabled = item.enabled;
237                let has_submenu = item.has_submenu();
238                let action = item.action.clone();
239                let close_on_activate = item.close_on_activate;
240                if !enabled {
241                    self.hover_path = None;
242                    return (EventResult::Consumed, MenuResponse::None);
243                }
244                self.hover_path = Some(path.clone());
245                if has_submenu {
246                    self.open_path = path;
247                    crate::animation::request_draw();
248                    (EventResult::Consumed, MenuResponse::None)
249                } else if let Some(action) = action {
250                    self.activate_action(items, &path, action, close_on_activate, true)
251                } else {
252                    (EventResult::Consumed, MenuResponse::None)
253                }
254            }
255            Some(MenuHit::Panel) => (EventResult::Consumed, MenuResponse::None),
256            None => {
257                self.close();
258                self.suppress_next_mouse_up = true;
259                crate::animation::request_draw();
260                (EventResult::Consumed, MenuResponse::Closed)
261            }
262        }
263    }
264
265    fn handle_release_activation(
266        &mut self,
267        items: &mut [MenuEntry],
268        pos: Point,
269        viewport: Size,
270    ) -> (EventResult, MenuResponse) {
271        let layouts = self.layouts(items, viewport);
272        match hit_test(&layouts, pos) {
273            Some(MenuHit::Item(path)) => {
274                self.hover_path = Some(path.clone());
275                let Some(item) = item_at_path(items, &path) else {
276                    return (EventResult::Consumed, MenuResponse::None);
277                };
278                let enabled = item.enabled;
279                let has_submenu = item.has_submenu();
280                let action = item.action.clone();
281                let close_on_activate = item.close_on_activate;
282                if !enabled || has_submenu {
283                    return (EventResult::Consumed, MenuResponse::None);
284                }
285                if let Some(action) = action {
286                    self.activate_action(items, &path, action, close_on_activate, false)
287                } else {
288                    (EventResult::Consumed, MenuResponse::None)
289                }
290            }
291            Some(MenuHit::Panel) | None => (EventResult::Consumed, MenuResponse::None),
292        }
293    }
294
295    fn activate_action(
296        &mut self,
297        items: &mut [MenuEntry],
298        path: &[usize],
299        action: String,
300        close_on_activate: bool,
301        suppress_mouse_up: bool,
302    ) -> (EventResult, MenuResponse) {
303        toggle_selection_at_path(items, path);
304        if close_on_activate {
305            self.close();
306            self.suppress_next_mouse_up = suppress_mouse_up;
307        }
308        crate::animation::request_draw();
309        (EventResult::Consumed, MenuResponse::Action(action))
310    }
311
312    fn handle_key(&mut self, items: &mut [MenuEntry], key: Key) -> (EventResult, MenuResponse) {
313        match key {
314            Key::Escape => {
315                self.close();
316                crate::animation::request_draw();
317                (EventResult::Consumed, MenuResponse::Closed)
318            }
319            Key::ArrowDown => {
320                self.step_hover(items, 1);
321                (EventResult::Consumed, MenuResponse::None)
322            }
323            Key::ArrowUp => {
324                self.step_hover(items, -1);
325                (EventResult::Consumed, MenuResponse::None)
326            }
327            Key::ArrowRight => {
328                if let Some(path) = self.hover_path.clone() {
329                    if item_at_path(items, &path).is_some_and(|item| item.has_submenu()) {
330                        self.open_path = path;
331                        crate::animation::request_draw();
332                    }
333                }
334                (EventResult::Consumed, MenuResponse::None)
335            }
336            Key::ArrowLeft => {
337                self.open_path.pop();
338                self.hover_path = self.open_path.last().map(|_| self.open_path.clone());
339                crate::animation::request_draw();
340                (EventResult::Consumed, MenuResponse::None)
341            }
342            Key::Enter | Key::Char(' ') => {
343                if let Some(path) = self.hover_path.clone() {
344                    if let Some(item) = item_at_path(items, &path) {
345                        let enabled = item.enabled;
346                        let has_submenu = item.has_submenu();
347                        let action = item.action.clone();
348                        let close_on_activate = item.close_on_activate;
349                        if enabled && has_submenu {
350                            self.open_path = path;
351                        } else if enabled {
352                            if let Some(action) = action {
353                                return self.activate_action(
354                                    items,
355                                    &path,
356                                    action,
357                                    close_on_activate,
358                                    false,
359                                );
360                            }
361                        }
362                    }
363                }
364                (EventResult::Consumed, MenuResponse::None)
365            }
366            _ => (EventResult::Ignored, MenuResponse::None),
367        }
368    }
369
370    fn step_hover(&mut self, items: &[MenuEntry], delta: isize) {
371        let level_items = items_at_path(items, &self.open_path).unwrap_or(items);
372        let enabled: Vec<usize> = level_items
373            .iter()
374            .enumerate()
375            .filter_map(|(idx, entry)| match entry {
376                MenuEntry::Item(item) if item.enabled => Some(idx),
377                _ => None,
378            })
379            .collect();
380        if enabled.is_empty() {
381            return;
382        }
383        let current = self
384            .hover_path
385            .as_ref()
386            .and_then(|path| path.last().copied())
387            .and_then(|idx| enabled.iter().position(|candidate| *candidate == idx));
388        let base = current
389            .map(|idx| idx as isize)
390            .unwrap_or(if delta > 0 { -1 } else { 0 });
391        let next = (base + delta).rem_euclid(enabled.len() as isize) as usize;
392        let mut path = self.open_path.clone();
393        path.push(enabled[next]);
394        self.hover_path = Some(path);
395        crate::animation::request_draw();
396    }
397}
398
399fn items_at_path<'a>(items: &'a [MenuEntry], path: &[usize]) -> Option<&'a [MenuEntry]> {
400    let mut current = items;
401    for &idx in path {
402        current = &item_at_path(current, &[idx])?.submenu;
403    }
404    Some(current)
405}
406
407fn toggle_selection_at_path(items: &mut [MenuEntry], path: &[usize]) {
408    let Some(selection) = item_at_path(items, path).map(|item| item.selection) else {
409        return;
410    };
411    match selection {
412        MenuSelection::Check { selected } => {
413            if let Some(item) = item_at_path_mut(items, path) {
414                item.selection = MenuSelection::Check {
415                    selected: !selected,
416                };
417            }
418        }
419        MenuSelection::Radio { .. } => {
420            let Some((&idx, parent_path)) = path.split_last() else {
421                return;
422            };
423            let Some(parent) = entries_at_path_mut(items, parent_path) else {
424                return;
425            };
426            for entry in parent.iter_mut() {
427                if let MenuEntry::Item(item) = entry {
428                    if matches!(item.selection, MenuSelection::Radio { .. }) {
429                        item.selection = MenuSelection::Radio { selected: false };
430                    }
431                }
432            }
433            if let Some(MenuEntry::Item(item)) = parent.get_mut(idx) {
434                item.selection = MenuSelection::Radio { selected: true };
435            }
436        }
437        MenuSelection::None => {}
438    }
439}
440
441fn item_at_path_mut<'a>(
442    items: &'a mut [MenuEntry],
443    path: &[usize],
444) -> Option<&'a mut super::model::MenuItem> {
445    let (&idx, rest) = path.split_first()?;
446    let entry = items.get_mut(idx)?;
447    match entry {
448        MenuEntry::Item(item) => {
449            if rest.is_empty() {
450                Some(item)
451            } else {
452                item_at_path_mut(&mut item.submenu, rest)
453            }
454        }
455        MenuEntry::Separator => None,
456    }
457}
458
459fn entries_at_path_mut<'a>(
460    items: &'a mut [MenuEntry],
461    path: &[usize],
462) -> Option<&'a mut [MenuEntry]> {
463    if path.is_empty() {
464        return Some(items);
465    }
466    let (&idx, rest) = path.split_first()?;
467    match items.get_mut(idx)? {
468        MenuEntry::Item(item) => entries_at_path_mut(&mut item.submenu, rest),
469        MenuEntry::Separator => None,
470    }
471}
472
473fn shortcut_path(items: &[MenuEntry], key: &Key, modifiers: Modifiers) -> Option<Vec<usize>> {
474    for (idx, entry) in items.iter().enumerate() {
475        let MenuEntry::Item(item) = entry else {
476            continue;
477        };
478        if item.enabled
479            && item
480                .accelerator
481                .is_some_and(|accelerator| accelerator.matches(key, modifiers))
482            && item.action.is_some()
483        {
484            return Some(vec![idx]);
485        }
486        if let Some(mut path) = shortcut_path(&item.submenu, key, modifiers) {
487            path.insert(0, idx);
488            return Some(path);
489        }
490    }
491    None
492}