Skip to main content

agg_gui/widgets/menu/widget/
mod.rs

1//! Widget adapters for reusable menus.
2//!
3//! `ContextMenu` is a small controller that other widgets can embed, while
4//! `MenuBar` is a visible widget for top-level menus.
5
6use std::sync::Arc;
7
8use crate::draw_ctx::DrawCtx;
9use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
10use crate::font_settings;
11use crate::geometry::{Point, Rect, Size};
12use crate::text::Font;
13use crate::widget::{current_viewport, Widget};
14
15use super::geometry::{contains, item_at_path, BAR_H};
16use super::model::MenuEntry;
17use super::paint::{paint_menu_bar_button, paint_popup_stack, MenuStyle};
18use super::state::{MenuAnchorKind, MenuResponse, PopupMenuState};
19
20/// Mouse events synthesised from a touch tap arrive within a few
21/// milliseconds of the corresponding `touchstart`/`touchend`.  Allow a
22/// generous window (50 ms) so a busy frame doesn't accidentally
23/// classify a synthesised event as a desktop click.
24const TOUCH_SYNTH_WINDOW_MS: u128 = 50;
25
26fn is_touch_synthesized() -> bool {
27    crate::touch_state::last_touch_event_age()
28        .map(|d| d.as_millis() < TOUCH_SYNTH_WINDOW_MS)
29        .unwrap_or(false)
30}
31
32#[derive(Clone)]
33pub struct PopupMenu {
34    pub items: Vec<MenuEntry>,
35    pub state: PopupMenuState,
36    pub style: MenuStyle,
37}
38
39impl PopupMenu {
40    pub fn new(items: Vec<MenuEntry>) -> Self {
41        Self {
42            items,
43            state: PopupMenuState::default(),
44            style: MenuStyle::default(),
45        }
46    }
47
48    pub fn open_at(&mut self, pos: Point) {
49        self.state.open_at(pos, MenuAnchorKind::Context);
50    }
51
52    pub fn close(&mut self) {
53        self.state.close();
54    }
55
56    pub fn is_open(&self) -> bool {
57        self.state.open
58    }
59
60    pub fn take_suppress_mouse_up(&mut self) -> bool {
61        self.state.take_suppress_mouse_up()
62    }
63
64    pub fn handle_event(&mut self, event: &Event, viewport: Size) -> (EventResult, MenuResponse) {
65        self.state.handle_event(&mut self.items, event, viewport)
66    }
67
68    /// Return `true` if `pos` falls inside any of the popup's currently
69    /// laid-out panels (the open menu plus any nested submenus).  Used
70    /// by `MenuBar` to detect a mouse-up in "neutral space" — outside
71    /// both the menu bar AND the popup body — so the bar can dismiss
72    /// the popup without waiting for a follow-up event.
73    pub fn body_contains(&self, pos: Point, viewport: Size) -> bool {
74        self.state
75            .layouts(&self.items, viewport)
76            .iter()
77            .any(|layout| {
78                pos.x >= layout.rect.x
79                    && pos.x <= layout.rect.x + layout.rect.width
80                    && pos.y >= layout.rect.y
81                    && pos.y <= layout.rect.y + layout.rect.height
82            })
83    }
84
85    pub fn handle_shortcut(&mut self, key: &Key, modifiers: Modifiers) -> MenuResponse {
86        self.state.handle_shortcut(&mut self.items, key, modifiers)
87    }
88
89    pub fn paint(&self, ctx: &mut dyn DrawCtx, font: Arc<Font>, font_size: f64, viewport: Size) {
90        let layouts = self.state.layouts(&self.items, viewport);
91        paint_popup_stack(
92            ctx,
93            font,
94            font_size,
95            &self.items,
96            &self.state,
97            &layouts,
98            &self.style,
99        );
100    }
101}
102
103pub struct MenuBar {
104    bounds: Rect,
105    children: Vec<Box<dyn Widget>>,
106    font: Arc<Font>,
107    font_size: f64,
108    menus: Vec<TopMenu>,
109    open_index: Option<usize>,
110    hover_index: Option<usize>,
111    popup: PopupMenu,
112    on_action: Box<dyn FnMut(&str)>,
113    /// Top-menu index whose hover highlight should NOT paint until the
114    /// cursor leaves it.  Set when the user closes a popup by clicking
115    /// the currently-open top menu's bar item — without this the bar
116    /// would keep showing the hover-tinted background after the close
117    /// (the cursor is still over the bar item) and read as "still
118    /// selected" to the user.  Cleared in `set_hover_index` when the
119    /// hovered idx changes to anything else.
120    suppress_hover_for: Option<usize>,
121    /// When `true`, [`Widget::layout`] returns the tight content width
122    /// (sum of menu-button widths) instead of the full available width.
123    /// Set via [`MenuBar::with_fit_width`] when the bar shares a FlexRow
124    /// with right-aligned chrome (e.g. project title, About button) and
125    /// shouldn't claim every spare pixel.
126    fit_width: bool,
127}
128
129pub struct TopMenu {
130    pub label: String,
131    pub items: Vec<MenuEntry>,
132    rect: Rect,
133}
134
135impl TopMenu {
136    pub fn new(label: impl Into<String>, items: Vec<MenuEntry>) -> Self {
137        Self {
138            label: label.into(),
139            items,
140            rect: Rect::default(),
141        }
142    }
143}
144
145impl MenuBar {
146    pub fn new(
147        font: Arc<Font>,
148        menus: Vec<TopMenu>,
149        on_action: impl FnMut(&str) + 'static,
150    ) -> Self {
151        Self {
152            bounds: Rect::default(),
153            children: Vec::new(),
154            font,
155            font_size: 14.0,
156            menus,
157            open_index: None,
158            hover_index: None,
159            popup: PopupMenu::new(Vec::new()),
160            on_action: Box::new(on_action),
161            suppress_hover_for: None,
162            fit_width: false,
163        }
164    }
165
166    /// Opt into tight-width sizing — `Widget::layout` will report the
167    /// summed menu-button width rather than the full available width.
168    /// Use when the MenuBar is hosted inside a `FlexRow` with sibling
169    /// chrome on the right (project title, status indicators, etc.)
170    /// that needs to share the same row.
171    pub fn with_fit_width(mut self, fit: bool) -> Self {
172        self.fit_width = fit;
173        self
174    }
175
176    pub fn with_font_size(mut self, font_size: f64) -> Self {
177        self.font_size = font_size;
178        self
179    }
180
181    /// Resolve the font used for layout/paint.  Prefers the system-wide
182    /// font override so the System window's font picker propagates live;
183    /// falls back to the per-instance font otherwise.  Mirrors the
184    /// `Label::active_font` pattern.
185    fn active_font(&self) -> Arc<Font> {
186        font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font))
187    }
188
189    fn menu_at(&self, pos: Point) -> Option<usize> {
190        self.menus.iter().position(|menu| contains(menu.rect, pos))
191    }
192
193    fn open_menu(&mut self, idx: usize) {
194        let rect = self.menus[idx].rect;
195        self.popup.items = self.menus[idx].items.clone();
196        self.popup
197            .state
198            .open_at(Point::new(rect.x, rect.y), MenuAnchorKind::Bar);
199        self.open_index = Some(idx);
200        self.hover_index = Some(idx);
201        crate::animation::request_draw();
202    }
203
204    fn open_menu_for_drag_release(&mut self, idx: usize) {
205        self.open_menu(idx);
206        self.popup.state.arm_mouse_up_activation();
207    }
208
209    fn switch_open_menu(&mut self, delta: isize) -> EventResult {
210        let Some(current) = self.open_index else {
211            return EventResult::Ignored;
212        };
213        if self.menus.is_empty() {
214            return EventResult::Ignored;
215        }
216        let len = self.menus.len() as isize;
217        let next = (current as isize + delta).rem_euclid(len) as usize;
218        self.open_menu(next);
219        EventResult::Consumed
220    }
221
222    fn should_switch_top_menu(&self, key: &Key) -> bool {
223        match key {
224            Key::ArrowLeft => self.popup.state.open_path.is_empty(),
225            Key::ArrowRight => {
226                if !self.popup.state.open_path.is_empty() {
227                    return false;
228                }
229                self.popup
230                    .state
231                    .hover_path
232                    .as_deref()
233                    .and_then(|path| item_at_path(&self.popup.items, path))
234                    .map_or(true, |item| !item.has_submenu())
235            }
236            _ => false,
237        }
238    }
239
240    fn set_hover_index(&mut self, hover: Option<usize>) {
241        // Touch devices have no real cursor; the synth-MouseMove fired
242        // alongside a touchstart would otherwise paint a hover panel that
243        // sticks after the tap (no MouseMove ever leaves the bar to clear
244        // it).  Coerce hover to `None` for any input within the touch-synth
245        // window so a tap-to-open / tap-to-close cycle leaves no residue.
246        let hover = if is_touch_synthesized() { None } else { hover };
247        if self.hover_index != hover {
248            self.hover_index = hover;
249            // `request_draw()` (NOT `_without_invalidation`) — the bar's
250            // hover paint lives inside the parent Window's retained
251            // backbuffer, so the cache must invalidate or the next paint
252            // composites a stale bitmap.  The epoch bump in `request_draw`
253            // is what `dispatch_event` reads to mark the ancestor path
254            // dirty even when this MouseMove returns `Ignored`.
255            crate::animation::request_draw();
256        }
257        // Cursor moved to a different top-menu (or off any) — clear
258        // the post-close hover suppression so the next genuine hover
259        // re-enters with the usual highlight.
260        if self.suppress_hover_for != hover {
261            self.suppress_hover_for = None;
262        }
263    }
264}
265
266impl Widget for MenuBar {
267    fn type_name(&self) -> &'static str {
268        "MenuBar"
269    }
270
271    fn bounds(&self) -> Rect {
272        self.bounds
273    }
274
275    fn set_bounds(&mut self, bounds: Rect) {
276        self.bounds = bounds;
277    }
278
279    fn children(&self) -> &[Box<dyn Widget>] {
280        &self.children
281    }
282
283    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
284        &mut self.children
285    }
286
287    fn layout(&mut self, available: Size) -> Size {
288        let mut x = 0.0;
289        for menu in &mut self.menus {
290            let width = (menu.label.chars().count() as f64 * 8.0 + 22.0).max(52.0);
291            menu.rect = Rect::new(x, 0.0, width, BAR_H);
292            x += width;
293        }
294        // `fit_width` mode reports the tight content width so a parent
295        // FlexRow can place sibling widgets to the right of the bar.
296        // Default mode keeps the historical behaviour (full available
297        // width — the bar paints its background across the whole row).
298        let report_w = if self.fit_width { x } else { available.width };
299        Size::new(report_w, BAR_H)
300    }
301
302    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
303        ctx.set_font(self.active_font());
304        ctx.set_font_size(self.font_size);
305        let v = ctx.visuals();
306        ctx.set_fill_color(v.top_bar_bg);
307        ctx.begin_path();
308        ctx.rect(0.0, 0.0, self.bounds.width, BAR_H);
309        ctx.fill();
310        for (idx, menu) in self.menus.iter().enumerate() {
311            // After a click-to-close-toggle, the cursor is still over
312            // the bar item so `hover_index` still points at it —
313            // suppress the hover highlight until the cursor moves off
314            // and back on, so the closed menu doesn't read as "still
315            // selected".
316            let hovered = self.hover_index == Some(idx)
317                && self.suppress_hover_for != Some(idx);
318            paint_menu_bar_button(
319                ctx,
320                menu.rect,
321                &menu.label,
322                self.open_index == Some(idx),
323                hovered,
324            );
325        }
326    }
327
328    fn hit_test_global_overlay(&self, _local_pos: Point) -> bool {
329        self.popup.is_open()
330    }
331
332    fn has_active_modal(&self) -> bool {
333        self.popup.is_open()
334    }
335
336    fn on_event(&mut self, event: &Event) -> EventResult {
337        if let Event::MouseMove { pos } = event {
338            let hovered = self.menu_at(*pos);
339            self.set_hover_index(hovered);
340            // Hover-switch a different open menu while a popup is open.
341            // Suppressed when this MouseMove was synthesised by the
342            // touch shell from a touchstart — on mobile the synth move
343            // arrives at the tap position immediately followed by a
344            // synth MouseDown at the same point; switching the open
345            // menu here would make that MouseDown look like a click on
346            // the currently-open menu and toggle-close the popup the
347            // user just tapped to open.  On desktop the
348            // `last_touch_event_age` is `None` (or very large), so
349            // hover-switch works as before.
350            let from_touch = is_touch_synthesized();
351            if self.popup.is_open() && !from_touch {
352                if let Some(idx) = hovered {
353                    if self.open_index != Some(idx) {
354                        let activate_on_release = self.popup.state.is_mouse_up_activation_armed();
355                        self.open_menu(idx);
356                        if activate_on_release {
357                            self.popup.state.arm_mouse_up_activation();
358                        }
359                    }
360                    return EventResult::Consumed;
361                }
362            }
363        }
364        if self.popup.is_open() {
365            if let Event::KeyDown { key, .. } = event {
366                if self.should_switch_top_menu(key) {
367                    return match key {
368                        Key::ArrowLeft => self.switch_open_menu(-1),
369                        Key::ArrowRight => self.switch_open_menu(1),
370                        _ => EventResult::Ignored,
371                    };
372                }
373            }
374            // Tap-to-switch: when one menu is already open and a
375            // MouseDown lands on a DIFFERENT top menu's bar, switch
376            // directly.  Without this, the popup handler would see the
377            // MouseDown as outside-the-popup-body and close the menu,
378            // leaving the user staring at an empty bar.  Clicking the
379            // currently-open menu falls through to the popup so it can
380            // close (toggle, the desktop convention).
381            if let Event::MouseDown {
382                pos,
383                button: MouseButton::Left,
384                ..
385            } = event
386            {
387                if let Some(idx) = self.menu_at(*pos) {
388                    if self.open_index != Some(idx) {
389                        self.open_menu(idx);
390                        return EventResult::Consumed;
391                    }
392                }
393            }
394            // Drag-release in neutral space cancels.  The user pressed
395            // a top menu, dragged off both the bar and the popup body,
396            // and let go — the standard menu convention is to dismiss.
397            // The popup state's drag-release handler treats outside-
398            // popup-body as a no-op (so a mouse-up still on the bar
399            // doesn't close), so the bar enforces the cancel here
400            // since only the bar knows where its own top-menu rects
401            // live.
402            if let Event::MouseUp {
403                pos,
404                button: MouseButton::Left,
405                ..
406            } = event
407            {
408                if self.popup.state.is_mouse_up_activation_armed()
409                    && self.menu_at(*pos).is_none()
410                    && !self.popup.body_contains(*pos, current_viewport())
411                {
412                    self.popup.close();
413                    self.open_index = None;
414                    crate::animation::request_draw();
415                    return EventResult::Consumed;
416                }
417            }
418            let (result, response) = self.popup.handle_event(event, current_viewport());
419            if let MenuResponse::Action(action) = response {
420                if let Some(idx) = self.open_index {
421                    self.menus[idx].items = self.popup.items.clone();
422                }
423                (self.on_action)(&action);
424                if !self.popup.is_open() {
425                    self.open_index = None;
426                }
427            } else if matches!(response, MenuResponse::Closed) {
428                self.open_index = None;
429                // Suppress the hover highlight on the menu the cursor
430                // is still over — without this, click-to-close-toggle
431                // leaves the bar item painted in the hover tint and
432                // reads as "still selected".  Cleared once the cursor
433                // moves to a different top-menu (or off the bar).
434                self.suppress_hover_for = self.hover_index;
435            }
436            if result == EventResult::Consumed {
437                return result;
438            }
439        }
440        match event {
441            Event::MouseDown {
442                pos,
443                button: MouseButton::Left,
444                ..
445            } => {
446                if let Some(idx) = self.menu_at(*pos) {
447                    self.open_menu_for_drag_release(idx);
448                    EventResult::Consumed
449                } else {
450                    EventResult::Ignored
451                }
452            }
453            Event::MouseMove { .. } => EventResult::Ignored,
454            _ => EventResult::Ignored,
455        }
456    }
457
458    fn on_unconsumed_key(&mut self, key: &Key, modifiers: Modifiers) -> EventResult {
459        let response = if self.popup.is_open() {
460            self.popup.handle_shortcut(key, modifiers)
461        } else {
462            self.menus
463                .iter_mut()
464                .find_map(|menu| {
465                    let mut popup = PopupMenu::new(menu.items.clone());
466                    match popup.handle_shortcut(key, modifiers) {
467                        MenuResponse::Action(action) => {
468                            menu.items = popup.items;
469                            Some(action)
470                        }
471                        MenuResponse::None | MenuResponse::Closed => None,
472                    }
473                })
474                .map(MenuResponse::Action)
475                .unwrap_or(MenuResponse::None)
476        };
477        if let MenuResponse::Action(action) = response {
478            if let Some(idx) = self.open_index {
479                self.menus[idx].items = self.popup.items.clone();
480            }
481            (self.on_action)(&action);
482            if !self.popup.is_open() {
483                self.open_index = None;
484            }
485            EventResult::Consumed
486        } else {
487            EventResult::Ignored
488        }
489    }
490
491    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
492        self.popup.paint(ctx, self.active_font(), self.font_size, current_viewport());
493    }
494}
495
496#[cfg(test)]
497mod tests_1;
498#[cfg(test)]
499mod tests_2;