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, BackbufferCache, Widget};
14
15use super::geometry::{contains, effective_metrics, item_at_path, DEFAULT_FONT_SIZE};
16use super::model::MenuEntry;
17use super::paint::{bar_button_text_color, paint_menu_bar_button_bg, paint_panel, MenuStyle};
18use super::state::{MenuAnchorKind, MenuResponse, PopupMenuState};
19
20use labels::{BarLabels, PopupLabels};
21
22mod labels;
23mod popup_paint;
24
25/// Layout direction for `MenuBar`.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum MenuOrientation {
28    Horizontal,
29    HorizontalBottom,
30    Vertical,
31}
32
33/// Mouse events synthesised from a touch tap arrive within a few
34/// milliseconds of the corresponding `touchstart`/`touchend`.  Allow a
35/// generous window (50 ms) so a busy frame doesn't accidentally
36/// classify a synthesised event as a desktop click.
37const TOUCH_SYNTH_WINDOW_MS: u128 = 50;
38
39fn is_touch_synthesized() -> bool {
40    crate::touch_state::last_touch_event_age()
41        .map(|d| d.as_millis() < TOUCH_SYNTH_WINDOW_MS)
42        .unwrap_or(false)
43}
44
45#[derive(Clone)]
46pub struct PopupMenu {
47    pub items: Vec<MenuEntry>,
48    pub state: PopupMenuState,
49    pub style: MenuStyle,
50    /// Cached row labels for popup text and shortcuts.
51    labels: PopupLabels,
52}
53
54impl PopupMenu {
55    pub fn new(items: Vec<MenuEntry>) -> Self {
56        Self {
57            items,
58            state: PopupMenuState::default(),
59            style: MenuStyle::default(),
60            labels: PopupLabels::new(),
61        }
62    }
63
64    pub fn open_at(&mut self, pos: Point) {
65        self.state.open_at(pos, MenuAnchorKind::Context);
66    }
67
68    pub fn close(&mut self) {
69        self.state.close();
70    }
71
72    pub fn is_open(&self) -> bool {
73        self.state.open
74    }
75
76    pub fn take_suppress_mouse_up(&mut self) -> bool {
77        self.state.take_suppress_mouse_up()
78    }
79
80    pub fn handle_event(&mut self, event: &Event, viewport: Size) -> (EventResult, MenuResponse) {
81        self.state.handle_event(&mut self.items, event, viewport)
82    }
83
84    /// Return `true` if `pos` falls inside any of the popup's currently
85    /// laid-out panels (the open menu plus any nested submenus).  Used
86    /// by `MenuBar` to detect a mouse-up in "neutral space" — outside
87    /// both the menu bar AND the popup body — so the bar can dismiss
88    /// the popup without waiting for a follow-up event.
89    pub fn body_contains(&self, pos: Point, viewport: Size) -> bool {
90        self.state
91            .layouts(&self.items, viewport)
92            .iter()
93            .any(|layout| {
94                pos.x >= layout.rect.x
95                    && pos.x <= layout.rect.x + layout.rect.width
96                    && pos.y >= layout.rect.y
97                    && pos.y <= layout.rect.y + layout.rect.height
98            })
99    }
100
101    pub fn handle_shortcut(&mut self, key: &Key, modifiers: Modifiers) -> MenuResponse {
102        self.state.handle_shortcut(&mut self.items, key, modifiers)
103    }
104
105    pub fn paint(
106        &mut self,
107        ctx: &mut dyn DrawCtx,
108        font: Arc<Font>,
109        font_size: f64,
110        viewport: Size,
111    ) {
112        let layouts = self.state.layouts(&self.items, viewport);
113        // Refresh the per-row `Label` cache against the current open
114        // tree.  Cheap when nothing changed — `sync_to` only mutates
115        // entries whose text or font differs from the cached state.
116        self.labels.sync_to(&font, font_size, &self.items, &layouts);
117
118        // Set the popup's default font / size on the ctx for the
119        // inline glyphs we still paint directly (icons, check / radio
120        // marks, submenu chevron).  Label widgets push their own font
121        // through their internal `set_font` call so this only affects
122        // the inline glyphs.
123        ctx.set_font(Arc::clone(&font));
124        ctx.set_font_size(font_size);
125
126        for (level_idx, layout) in layouts.iter().enumerate() {
127            paint_panel(ctx, layout.rect, &self.style);
128            popup_paint::paint_popup_level(
129                ctx,
130                level_idx,
131                layout,
132                &self.items,
133                &self.state,
134                &self.style,
135                &mut self.labels,
136            );
137        }
138    }
139}
140
141pub struct MenuBar {
142    bounds: Rect,
143    children: Vec<Box<dyn Widget>>,
144    font: Arc<Font>,
145    /// Explicit caller override from [`MenuBar::with_font_size`].  `None`
146    /// means "auto": the size tracks [`effective_metrics`] so touch menus
147    /// grow their text in lock-step with their rows.  An explicit override
148    /// always wins (the caller knows best).
149    font_size: Option<f64>,
150    menus: Vec<TopMenu>,
151    open_index: Option<usize>,
152    hover_index: Option<usize>,
153    popup: PopupMenu,
154    on_action: Box<dyn FnMut(&str)>,
155    /// Top-menu index whose hover highlight is suppressed until cursor exit.
156    suppress_hover_for: Option<usize>,
157    /// When `true`, [`Widget::layout`] returns the tight content width
158    /// (sum of menu-button widths) instead of the full available width.
159    /// Set via [`MenuBar::with_fit_width`] when the bar shares a FlexRow
160    /// with right-aligned chrome (e.g. project title, About button) and
161    /// shouldn't claim every spare pixel.
162    fit_width: bool,
163    orientation: MenuOrientation,
164    /// CPU backbuffer cache for the mostly-static bar pixels.
165    cache: BackbufferCache,
166    /// Cached labels for each top-menu bar button.
167    bar_labels: BarLabels,
168}
169
170pub struct TopMenu {
171    pub label: String,
172    pub items: Vec<MenuEntry>,
173    rect: Rect,
174}
175
176impl TopMenu {
177    pub fn new(label: impl Into<String>, items: Vec<MenuEntry>) -> Self {
178        Self {
179            label: label.into(),
180            items,
181            rect: Rect::default(),
182        }
183    }
184}
185
186impl MenuBar {
187    pub fn new(
188        font: Arc<Font>,
189        menus: Vec<TopMenu>,
190        on_action: impl FnMut(&str) + 'static,
191    ) -> Self {
192        Self {
193            bounds: Rect::default(),
194            children: Vec::new(),
195            font,
196            font_size: None,
197            menus,
198            open_index: None,
199            hover_index: None,
200            popup: PopupMenu::new(Vec::new()),
201            on_action: Box::new(on_action),
202            suppress_hover_for: None,
203            fit_width: false,
204            orientation: MenuOrientation::Horizontal,
205            cache: BackbufferCache::new(),
206            bar_labels: BarLabels::new(),
207        }
208    }
209
210    /// Refresh the per-button label cache to match `self.menus`.
211    fn sync_bar_labels(&mut self) {
212        let labels: Vec<&str> = self.menus.iter().map(|m| m.label.as_str()).collect();
213        let font_size = self.effective_font_size();
214        self.bar_labels
215            .sync_to(&self.active_font(), font_size, &labels);
216    }
217
218    /// Use a vertical layout — the bar stacks its menu buttons top-to-
219    /// bottom (Y-up: highest local Y first) and opens popups to the
220    /// RIGHT of each button. Intended for narrow, tall chrome strips
221    /// such as a left-side mobile sidebar.
222    pub fn with_orientation(mut self, orientation: MenuOrientation) -> Self {
223        self.orientation = orientation;
224        self
225    }
226
227    /// Opt into tight-width sizing — `Widget::layout` will report the
228    /// summed menu-button width rather than the full available width.
229    /// Use when the MenuBar is hosted inside a `FlexRow` with sibling
230    /// chrome on the right (project title, status indicators, etc.)
231    /// that needs to share the same row.
232    pub fn with_fit_width(mut self, fit: bool) -> Self {
233        self.fit_width = fit;
234        self
235    }
236
237    pub fn with_font_size(mut self, font_size: f64) -> Self {
238        self.font_size = Some(font_size);
239        self
240    }
241
242    /// Resolve the text size the bar / its popup should use this frame.
243    /// An explicit [`with_font_size`](Self::with_font_size) override wins;
244    /// otherwise the size comes from [`effective_metrics`], which grows it
245    /// on touch so the label stays proportional to the (grown) row.
246    fn effective_font_size(&self) -> f64 {
247        self.font_size
248            .unwrap_or_else(|| effective_metrics().default_font_size)
249    }
250
251    /// Override the popup's [`MenuStyle`] — geometry and inline-glyph
252    /// characters (submenu chevron, check mark, radio mark).  Hosts
253    /// that bundle Font Awesome typically swap the default Unicode
254    /// chars for FA equivalents so the menu indicators visually match
255    /// the icons used everywhere else.
256    pub fn with_menu_style(mut self, style: MenuStyle) -> Self {
257        self.popup.style = style;
258        self.cache.invalidate();
259        self
260    }
261
262    /// Replace the bar's top-level menu list at runtime.  Used by callers
263    /// that derive menu contents from app state (e.g. radio-style theme
264    /// pickers) and need to refresh the items each frame so the popup's
265    /// check/radio marks reflect the canonical state.  Invalidates the
266    /// backbuffer cache so the next paint re-rasters bar labels.
267    pub fn set_menus(&mut self, menus: Vec<TopMenu>) {
268        self.menus = menus;
269        self.cache.invalidate();
270    }
271
272    /// Read-only access to the configured top-level menus.  Mainly for
273    /// tests that need to inspect labels / items without going through
274    /// the popup state machine.
275    pub fn menus(&self) -> &[TopMenu] {
276        &self.menus
277    }
278
279    /// Resolve the font used for layout/paint.  Prefers the system-wide
280    /// font override so the System window's font picker propagates live;
281    /// falls back to the per-instance font otherwise.  Mirrors the
282    /// `Label::active_font` pattern.
283    fn active_font(&self) -> Arc<Font> {
284        font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font))
285    }
286
287    fn menu_at(&self, pos: Point) -> Option<usize> {
288        self.menus.iter().position(|menu| contains(menu.rect, pos))
289    }
290
291    fn open_menu(&mut self, idx: usize) {
292        let rect = self.menus[idx].rect;
293        self.popup.items = self.menus[idx].items.clone();
294        // Horizontal: anchor at the BAR'S bottom-left (rect.x, rect.y)
295        // — popup opens straight DOWN under the bar item, allowed to
296        // extend off-bar via the `Bar` kind's negative-y clamp.
297        //
298        // Vertical: anchor at the BUTTON'S top-right corner — popup
299        // opens to the RIGHT of the button with its top aligned to the
300        // button's top. `Context` kind clamps the popup inside the
301        // viewport so we don't trail off the top.
302        let (anchor, kind) = match self.orientation {
303            MenuOrientation::Horizontal => (Point::new(rect.x, rect.y), MenuAnchorKind::Bar),
304            // Anchor at the bar item's TOP edge so the popup
305            // rises FROM it instead of hanging below.
306            MenuOrientation::HorizontalBottom => (
307                Point::new(rect.x, rect.y + rect.height),
308                MenuAnchorKind::BottomBar,
309            ),
310            MenuOrientation::Vertical => (
311                Point::new(rect.x + rect.width, rect.y + rect.height),
312                MenuAnchorKind::Context,
313            ),
314        };
315        self.popup.state.open_at(anchor, kind);
316        self.open_index = Some(idx);
317        self.hover_index = Some(idx);
318        self.cache.invalidate();
319        crate::animation::request_draw();
320    }
321
322    fn open_menu_for_drag_release(&mut self, idx: usize) {
323        self.open_menu(idx);
324        self.popup.state.arm_mouse_up_activation();
325    }
326
327    fn switch_open_menu(&mut self, delta: isize) -> EventResult {
328        let Some(current) = self.open_index else {
329            return EventResult::Ignored;
330        };
331        if self.menus.is_empty() {
332            return EventResult::Ignored;
333        }
334        let len = self.menus.len() as isize;
335        let next = (current as isize + delta).rem_euclid(len) as usize;
336        self.open_menu(next);
337        EventResult::Consumed
338    }
339
340    fn should_switch_top_menu(&self, key: &Key) -> bool {
341        match key {
342            Key::ArrowLeft => self.popup.state.open_path.is_empty(),
343            Key::ArrowRight => {
344                if !self.popup.state.open_path.is_empty() {
345                    return false;
346                }
347                self.popup
348                    .state
349                    .hover_path
350                    .as_deref()
351                    .and_then(|path| item_at_path(&self.popup.items, path))
352                    .map_or(true, |item| !item.has_submenu())
353            }
354            _ => false,
355        }
356    }
357
358    fn set_hover_index(&mut self, hover: Option<usize>) {
359        // Touch devices have no real cursor; the synth-MouseMove fired
360        // alongside a touchstart would otherwise paint a hover panel that
361        // sticks after the tap (no MouseMove ever leaves the bar to clear
362        // it).  Coerce hover to `None` for any input within the touch-synth
363        // window so a tap-to-open / tap-to-close cycle leaves no residue.
364        let hover = if is_touch_synthesized() { None } else { hover };
365        if self.hover_index != hover {
366            self.hover_index = hover;
367            // `request_draw()` (NOT `_without_invalidation`) — the bar's
368            // hover paint lives inside the parent Window's retained
369            // backbuffer, so the cache must invalidate or the next paint
370            // composites a stale bitmap.  The epoch bump in `request_draw`
371            // is what `dispatch_event` reads to mark the ancestor path
372            // dirty even when this MouseMove returns `Ignored`.
373            crate::animation::request_draw();
374            // The bar itself is backbuffered too — invalidate so the
375            // next paint re-rasterises the hover-tinted bar item.
376            self.cache.invalidate();
377        }
378        // Cursor moved to a different top-menu (or off any) — clear
379        // the post-close hover suppression so the next genuine hover
380        // re-enters with the usual highlight.
381        if self.suppress_hover_for != hover {
382            self.suppress_hover_for = None;
383            self.cache.invalidate();
384        }
385    }
386}
387
388impl Widget for MenuBar {
389    fn type_name(&self) -> &'static str {
390        "MenuBar"
391    }
392
393    fn bounds(&self) -> Rect {
394        self.bounds
395    }
396
397    fn set_bounds(&mut self, bounds: Rect) {
398        if (bounds.width - self.bounds.width).abs() > 0.5
399            || (bounds.height - self.bounds.height).abs() > 0.5
400        {
401            self.cache.invalidate();
402        }
403        self.bounds = bounds;
404    }
405
406    fn children(&self) -> &[Box<dyn Widget>] {
407        &self.children
408    }
409
410    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
411        &mut self.children
412    }
413
414    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
415        Some(&mut self.cache)
416    }
417
418    fn backbuffer_mode(&self) -> crate::widget::BackbufferMode {
419        // Mirror Label: when the global LCD toggle is on (which the
420        // default rule wires to "scale ≤ 1.25"), use the per-channel LCD
421        // coverage cache so text on the bar is subpixel-rendered exactly
422        // like Label text.  Falls back to RGBA at HiDPI where LCD gains
423        // nothing.  MenuBar paints `top_bar_bg` as an opaque full-width
424        // fill before any other content, satisfying LcdCoverage's
425        // "widget must cover its bounds with opaque content" contract.
426        if crate::font_settings::lcd_enabled() {
427            crate::widget::BackbufferMode::LcdCoverage
428        } else {
429            crate::widget::BackbufferMode::Rgba
430        }
431    }
432
433    fn layout(&mut self, available: Size) -> Size {
434        // Keep the bar's Label cache in lock-step with `self.menus`
435        // before measuring — handles dynamic menu lists.
436        self.sync_bar_labels();
437        let m = effective_metrics();
438        match self.orientation {
439            MenuOrientation::Horizontal | MenuOrientation::HorizontalBottom => {
440                // Scale each button's width by the same factor the text
441                // grew, so the label keeps the same slack on touch as on
442                // desktop and the button hit-rect always contains its text.
443                let wscale = self.effective_font_size() / DEFAULT_FONT_SIZE;
444                let mut x = 0.0;
445                for menu in &mut self.menus {
446                    let width = (menu.label.chars().count() as f64 * 8.0 + 22.0).max(52.0) * wscale;
447                    menu.rect = Rect::new(x, 0.0, width, m.bar_h);
448                    x += width;
449                }
450                // Fit-width mode leaves room for sibling chrome.
451                let report_w = if self.fit_width { x } else { available.width };
452                Size::new(report_w, m.bar_h)
453            }
454            MenuOrientation::Vertical => {
455                // Stack top-to-bottom in Y-up coordinates.
456                let mut y = available.height;
457                for menu in &mut self.menus {
458                    y -= m.vertical_row_h;
459                    menu.rect = Rect::new(0.0, y, available.width, m.vertical_row_h);
460                }
461                let used_h = self.menus.len() as f64 * m.vertical_row_h;
462                Size::new(available.width, used_h)
463            }
464        }
465    }
466
467    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
468        // Re-sync in case paint runs without a preceding layout.
469        self.sync_bar_labels();
470        ctx.set_font(self.active_font());
471        ctx.set_font_size(self.effective_font_size());
472        let v = ctx.visuals();
473        ctx.set_fill_color(v.top_bar_bg);
474        ctx.begin_path();
475        let bg_h = match self.orientation {
476            MenuOrientation::Horizontal | MenuOrientation::HorizontalBottom => {
477                effective_metrics().bar_h
478            }
479            MenuOrientation::Vertical => self.bounds.height,
480        };
481        ctx.rect(0.0, 0.0, self.bounds.width, bg_h);
482        ctx.fill();
483        // First pass: button chrome under the text.
484        for (idx, menu) in self.menus.iter().enumerate() {
485            // After a click-to-close-toggle, the cursor is still over
486            // the bar item so `hover_index` still points at it —
487            // suppress the hover highlight until the cursor moves off
488            // and back on, so the closed menu doesn't read as "still
489            // selected".
490            let hovered = self.hover_index == Some(idx) && self.suppress_hover_for != Some(idx);
491            let open = self.open_index == Some(idx);
492            paint_menu_bar_button_bg(ctx, menu.rect, open, hovered);
493        }
494        // Second pass: paint each button's `Label` through
495        // `paint_subtree` so glyphs flow through Label's backbuffer +
496        // LCD path.  Done after backgrounds so the text composites on
497        // top of the hover fill.
498        let menu_rects: Vec<(Rect, bool)> = self
499            .menus
500            .iter()
501            .enumerate()
502            .map(|(idx, menu)| (menu.rect, self.open_index == Some(idx)))
503            .collect();
504        for (idx, (rect, open)) in menu_rects.into_iter().enumerate() {
505            let color = bar_button_text_color(ctx, open);
506            self.bar_labels.paint_in(ctx, idx, rect, color);
507        }
508    }
509
510    fn hit_test_global_overlay(&self, _local_pos: Point) -> bool {
511        self.popup.is_open()
512    }
513
514    fn has_active_modal(&self) -> bool {
515        self.popup.is_open()
516    }
517
518    fn on_event(&mut self, event: &Event) -> EventResult {
519        if let Event::MouseMove { pos } = event {
520            let hovered = self.menu_at(*pos);
521            self.set_hover_index(hovered);
522            // Hover-switch a different open menu while a popup is open.
523            // Suppressed when this MouseMove was synthesised by the
524            // touch shell from a touchstart — on mobile the synth move
525            // arrives at the tap position immediately followed by a
526            // synth MouseDown at the same point; switching the open
527            // menu here would make that MouseDown look like a click on
528            // the currently-open menu and toggle-close the popup the
529            // user just tapped to open.  On desktop the
530            // `last_touch_event_age` is `None` (or very large), so
531            // hover-switch works as before.
532            let from_touch = is_touch_synthesized();
533            if self.popup.is_open() && !from_touch {
534                if let Some(idx) = hovered {
535                    if self.open_index != Some(idx) {
536                        let activate_on_release = self.popup.state.is_mouse_up_activation_armed();
537                        self.open_menu(idx);
538                        if activate_on_release {
539                            self.popup.state.arm_mouse_up_activation();
540                        }
541                    }
542                    return EventResult::Consumed;
543                }
544            }
545        }
546        if self.popup.is_open() {
547            if let Event::KeyDown { key, .. } = event {
548                if self.should_switch_top_menu(key) {
549                    return match key {
550                        Key::ArrowLeft => self.switch_open_menu(-1),
551                        Key::ArrowRight => self.switch_open_menu(1),
552                        _ => EventResult::Ignored,
553                    };
554                }
555            }
556            // Tap-to-switch: when one menu is already open and a
557            // MouseDown lands on a DIFFERENT top menu's bar, switch
558            // directly.  Without this, the popup handler would see the
559            // MouseDown as outside-the-popup-body and close the menu,
560            // leaving the user staring at an empty bar.  Clicking the
561            // currently-open menu falls through to the popup so it can
562            // close (toggle, the desktop convention).
563            if let Event::MouseDown {
564                pos,
565                button: MouseButton::Left,
566                ..
567            } = event
568            {
569                if let Some(idx) = self.menu_at(*pos) {
570                    if self.open_index != Some(idx) {
571                        self.open_menu(idx);
572                        return EventResult::Consumed;
573                    }
574                }
575            }
576            // Drag-release in neutral space cancels.  The user pressed
577            // a top menu, dragged off both the bar and the popup body,
578            // and let go — the standard menu convention is to dismiss.
579            // The popup state's drag-release handler treats outside-
580            // popup-body as a no-op (so a mouse-up still on the bar
581            // doesn't close), so the bar enforces the cancel here
582            // since only the bar knows where its own top-menu rects
583            // live.
584            if let Event::MouseUp {
585                pos,
586                button: MouseButton::Left,
587                ..
588            } = event
589            {
590                if self.popup.state.is_mouse_up_activation_armed()
591                    && self.menu_at(*pos).is_none()
592                    && !self.popup.body_contains(*pos, current_viewport())
593                {
594                    self.popup.close();
595                    self.open_index = None;
596                    self.cache.invalidate();
597                    crate::animation::request_draw();
598                    return EventResult::Consumed;
599                }
600            }
601            let (result, response) = self.popup.handle_event(event, current_viewport());
602            if let MenuResponse::Action(action) = response {
603                if let Some(idx) = self.open_index {
604                    self.menus[idx].items = self.popup.items.clone();
605                }
606                (self.on_action)(&action);
607                if !self.popup.is_open() {
608                    self.open_index = None;
609                    self.cache.invalidate();
610                }
611            } else if matches!(response, MenuResponse::Closed) {
612                self.open_index = None;
613                // Suppress the hover highlight on the menu the cursor
614                // is still over — without this, click-to-close-toggle
615                // leaves the bar item painted in the hover tint and
616                // reads as "still selected".  Cleared once the cursor
617                // moves to a different top-menu (or off the bar).
618                self.suppress_hover_for = self.hover_index;
619                self.cache.invalidate();
620            }
621            if result.is_consumed() {
622                return result;
623            }
624        }
625        match event {
626            Event::MouseDown {
627                pos,
628                button: MouseButton::Left,
629                ..
630            } => {
631                if let Some(idx) = self.menu_at(*pos) {
632                    self.open_menu_for_drag_release(idx);
633                    EventResult::Consumed
634                } else {
635                    EventResult::Ignored
636                }
637            }
638            Event::MouseMove { .. } => EventResult::Ignored,
639            _ => EventResult::Ignored,
640        }
641    }
642
643    fn on_unconsumed_key(&mut self, key: &Key, modifiers: Modifiers) -> EventResult {
644        let response = if self.popup.is_open() {
645            self.popup.handle_shortcut(key, modifiers)
646        } else {
647            self.menus
648                .iter_mut()
649                .find_map(|menu| {
650                    let mut popup = PopupMenu::new(menu.items.clone());
651                    match popup.handle_shortcut(key, modifiers) {
652                        MenuResponse::Action(action) => {
653                            menu.items = popup.items;
654                            Some(action)
655                        }
656                        MenuResponse::None | MenuResponse::Closed => None,
657                    }
658                })
659                .map(MenuResponse::Action)
660                .unwrap_or(MenuResponse::None)
661        };
662        if let MenuResponse::Action(action) = response {
663            if let Some(idx) = self.open_index {
664                self.menus[idx].items = self.popup.items.clone();
665            }
666            (self.on_action)(&action);
667            if !self.popup.is_open() {
668                self.open_index = None;
669                self.cache.invalidate();
670            }
671            EventResult::Consumed
672        } else {
673            EventResult::Ignored
674        }
675    }
676
677    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
678        let font = self.active_font();
679        let font_size = self.effective_font_size();
680        self.popup.paint(ctx, font, font_size, current_viewport());
681    }
682}
683
684#[cfg(test)]
685mod tests_1;
686#[cfg(test)]
687mod tests_2;