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, item_at_path, BAR_H};
16use super::model::{MenuEntry, MenuSelection};
17use super::paint::{
18    bar_button_text_color, paint_check_mark, paint_item_row_bg, paint_menu_bar_button_bg,
19    paint_panel, paint_separator, paint_submenu_chevron, MenuStyle,
20};
21use super::state::{MenuAnchorKind, MenuResponse, PopupMenuState};
22
23use labels::{BarLabels, PopupLabels};
24
25mod labels;
26
27/// Layout direction for `MenuBar`.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum MenuOrientation {
30    Horizontal,
31    HorizontalBottom,
32    Vertical,
33}
34
35pub const VERTICAL_ROW_H: f64 = 36.0;
36
37/// Mouse events synthesised from a touch tap arrive within a few
38/// milliseconds of the corresponding `touchstart`/`touchend`.  Allow a
39/// generous window (50 ms) so a busy frame doesn't accidentally
40/// classify a synthesised event as a desktop click.
41const TOUCH_SYNTH_WINDOW_MS: u128 = 50;
42
43fn is_touch_synthesized() -> bool {
44    crate::touch_state::last_touch_event_age()
45        .map(|d| d.as_millis() < TOUCH_SYNTH_WINDOW_MS)
46        .unwrap_or(false)
47}
48
49#[derive(Clone)]
50pub struct PopupMenu {
51    pub items: Vec<MenuEntry>,
52    pub state: PopupMenuState,
53    pub style: MenuStyle,
54    /// Cached row labels for popup text and shortcuts.
55    labels: PopupLabels,
56}
57
58impl PopupMenu {
59    pub fn new(items: Vec<MenuEntry>) -> Self {
60        Self {
61            items,
62            state: PopupMenuState::default(),
63            style: MenuStyle::default(),
64            labels: PopupLabels::new(),
65        }
66    }
67
68    pub fn open_at(&mut self, pos: Point) {
69        self.state.open_at(pos, MenuAnchorKind::Context);
70    }
71
72    pub fn close(&mut self) {
73        self.state.close();
74    }
75
76    pub fn is_open(&self) -> bool {
77        self.state.open
78    }
79
80    pub fn take_suppress_mouse_up(&mut self) -> bool {
81        self.state.take_suppress_mouse_up()
82    }
83
84    pub fn handle_event(&mut self, event: &Event, viewport: Size) -> (EventResult, MenuResponse) {
85        self.state.handle_event(&mut self.items, event, viewport)
86    }
87
88    /// Return `true` if `pos` falls inside any of the popup's currently
89    /// laid-out panels (the open menu plus any nested submenus).  Used
90    /// by `MenuBar` to detect a mouse-up in "neutral space" — outside
91    /// both the menu bar AND the popup body — so the bar can dismiss
92    /// the popup without waiting for a follow-up event.
93    pub fn body_contains(&self, pos: Point, viewport: Size) -> bool {
94        self.state
95            .layouts(&self.items, viewport)
96            .iter()
97            .any(|layout| {
98                pos.x >= layout.rect.x
99                    && pos.x <= layout.rect.x + layout.rect.width
100                    && pos.y >= layout.rect.y
101                    && pos.y <= layout.rect.y + layout.rect.height
102            })
103    }
104
105    pub fn handle_shortcut(&mut self, key: &Key, modifiers: Modifiers) -> MenuResponse {
106        self.state.handle_shortcut(&mut self.items, key, modifiers)
107    }
108
109    pub fn paint(
110        &mut self,
111        ctx: &mut dyn DrawCtx,
112        font: Arc<Font>,
113        font_size: f64,
114        viewport: Size,
115    ) {
116        let layouts = self.state.layouts(&self.items, viewport);
117        // Refresh the per-row `Label` cache against the current open
118        // tree.  Cheap when nothing changed — `sync_to` only mutates
119        // entries whose text or font differs from the cached state.
120        self.labels.sync_to(&font, font_size, &self.items, &layouts);
121
122        // Set the popup's default font / size on the ctx for the
123        // inline glyphs we still paint directly (icons, check / radio
124        // marks, submenu chevron).  Label widgets push their own font
125        // through their internal `set_font` call so this only affects
126        // the inline glyphs.
127        ctx.set_font(Arc::clone(&font));
128        ctx.set_font_size(font_size);
129
130        for (level_idx, layout) in layouts.iter().enumerate() {
131            paint_panel(ctx, layout.rect, &self.style);
132            paint_popup_level(
133                ctx,
134                level_idx,
135                layout,
136                &self.items,
137                &self.state,
138                &self.style,
139                &mut self.labels,
140            );
141        }
142    }
143}
144
145pub struct MenuBar {
146    bounds: Rect,
147    children: Vec<Box<dyn Widget>>,
148    font: Arc<Font>,
149    font_size: 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: 14.0,
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        self.bar_labels
214            .sync_to(&self.active_font(), self.font_size, &labels);
215    }
216
217    /// Use a vertical layout — the bar stacks its menu buttons top-to-
218    /// bottom (Y-up: highest local Y first) and opens popups to the
219    /// RIGHT of each button. Intended for narrow, tall chrome strips
220    /// such as a left-side mobile sidebar.
221    pub fn with_orientation(mut self, orientation: MenuOrientation) -> Self {
222        self.orientation = orientation;
223        self
224    }
225
226    /// Opt into tight-width sizing — `Widget::layout` will report the
227    /// summed menu-button width rather than the full available width.
228    /// Use when the MenuBar is hosted inside a `FlexRow` with sibling
229    /// chrome on the right (project title, status indicators, etc.)
230    /// that needs to share the same row.
231    pub fn with_fit_width(mut self, fit: bool) -> Self {
232        self.fit_width = fit;
233        self
234    }
235
236    pub fn with_font_size(mut self, font_size: f64) -> Self {
237        self.font_size = font_size;
238        self
239    }
240
241    /// Override the popup's [`MenuStyle`] — geometry and inline-glyph
242    /// characters (submenu chevron, check mark, radio mark).  Hosts
243    /// that bundle Font Awesome typically swap the default Unicode
244    /// chars for FA equivalents so the menu indicators visually match
245    /// the icons used everywhere else.
246    pub fn with_menu_style(mut self, style: MenuStyle) -> Self {
247        self.popup.style = style;
248        self.cache.invalidate();
249        self
250    }
251
252    /// Replace the bar's top-level menu list at runtime.  Used by callers
253    /// that derive menu contents from app state (e.g. radio-style theme
254    /// pickers) and need to refresh the items each frame so the popup's
255    /// check/radio marks reflect the canonical state.  Invalidates the
256    /// backbuffer cache so the next paint re-rasters bar labels.
257    pub fn set_menus(&mut self, menus: Vec<TopMenu>) {
258        self.menus = menus;
259        self.cache.invalidate();
260    }
261
262    /// Read-only access to the configured top-level menus.  Mainly for
263    /// tests that need to inspect labels / items without going through
264    /// the popup state machine.
265    pub fn menus(&self) -> &[TopMenu] {
266        &self.menus
267    }
268
269    /// Resolve the font used for layout/paint.  Prefers the system-wide
270    /// font override so the System window's font picker propagates live;
271    /// falls back to the per-instance font otherwise.  Mirrors the
272    /// `Label::active_font` pattern.
273    fn active_font(&self) -> Arc<Font> {
274        font_settings::current_system_font().unwrap_or_else(|| Arc::clone(&self.font))
275    }
276
277    fn menu_at(&self, pos: Point) -> Option<usize> {
278        self.menus.iter().position(|menu| contains(menu.rect, pos))
279    }
280
281    fn open_menu(&mut self, idx: usize) {
282        let rect = self.menus[idx].rect;
283        self.popup.items = self.menus[idx].items.clone();
284        // Horizontal: anchor at the BAR'S bottom-left (rect.x, rect.y)
285        // — popup opens straight DOWN under the bar item, allowed to
286        // extend off-bar via the `Bar` kind's negative-y clamp.
287        //
288        // Vertical: anchor at the BUTTON'S top-right corner — popup
289        // opens to the RIGHT of the button with its top aligned to the
290        // button's top. `Context` kind clamps the popup inside the
291        // viewport so we don't trail off the top.
292        let (anchor, kind) = match self.orientation {
293            MenuOrientation::Horizontal => (Point::new(rect.x, rect.y), MenuAnchorKind::Bar),
294            // Anchor at the bar item's TOP edge so the popup
295            // rises FROM it instead of hanging below.
296            MenuOrientation::HorizontalBottom => (
297                Point::new(rect.x, rect.y + rect.height),
298                MenuAnchorKind::BottomBar,
299            ),
300            MenuOrientation::Vertical => (
301                Point::new(rect.x + rect.width, rect.y + rect.height),
302                MenuAnchorKind::Context,
303            ),
304        };
305        self.popup.state.open_at(anchor, kind);
306        self.open_index = Some(idx);
307        self.hover_index = Some(idx);
308        self.cache.invalidate();
309        crate::animation::request_draw();
310    }
311
312    fn open_menu_for_drag_release(&mut self, idx: usize) {
313        self.open_menu(idx);
314        self.popup.state.arm_mouse_up_activation();
315    }
316
317    fn switch_open_menu(&mut self, delta: isize) -> EventResult {
318        let Some(current) = self.open_index else {
319            return EventResult::Ignored;
320        };
321        if self.menus.is_empty() {
322            return EventResult::Ignored;
323        }
324        let len = self.menus.len() as isize;
325        let next = (current as isize + delta).rem_euclid(len) as usize;
326        self.open_menu(next);
327        EventResult::Consumed
328    }
329
330    fn should_switch_top_menu(&self, key: &Key) -> bool {
331        match key {
332            Key::ArrowLeft => self.popup.state.open_path.is_empty(),
333            Key::ArrowRight => {
334                if !self.popup.state.open_path.is_empty() {
335                    return false;
336                }
337                self.popup
338                    .state
339                    .hover_path
340                    .as_deref()
341                    .and_then(|path| item_at_path(&self.popup.items, path))
342                    .map_or(true, |item| !item.has_submenu())
343            }
344            _ => false,
345        }
346    }
347
348    fn set_hover_index(&mut self, hover: Option<usize>) {
349        // Touch devices have no real cursor; the synth-MouseMove fired
350        // alongside a touchstart would otherwise paint a hover panel that
351        // sticks after the tap (no MouseMove ever leaves the bar to clear
352        // it).  Coerce hover to `None` for any input within the touch-synth
353        // window so a tap-to-open / tap-to-close cycle leaves no residue.
354        let hover = if is_touch_synthesized() { None } else { hover };
355        if self.hover_index != hover {
356            self.hover_index = hover;
357            // `request_draw()` (NOT `_without_invalidation`) — the bar's
358            // hover paint lives inside the parent Window's retained
359            // backbuffer, so the cache must invalidate or the next paint
360            // composites a stale bitmap.  The epoch bump in `request_draw`
361            // is what `dispatch_event` reads to mark the ancestor path
362            // dirty even when this MouseMove returns `Ignored`.
363            crate::animation::request_draw();
364            // The bar itself is backbuffered too — invalidate so the
365            // next paint re-rasterises the hover-tinted bar item.
366            self.cache.invalidate();
367        }
368        // Cursor moved to a different top-menu (or off any) — clear
369        // the post-close hover suppression so the next genuine hover
370        // re-enters with the usual highlight.
371        if self.suppress_hover_for != hover {
372            self.suppress_hover_for = None;
373            self.cache.invalidate();
374        }
375    }
376}
377
378impl Widget for MenuBar {
379    fn type_name(&self) -> &'static str {
380        "MenuBar"
381    }
382
383    fn bounds(&self) -> Rect {
384        self.bounds
385    }
386
387    fn set_bounds(&mut self, bounds: Rect) {
388        if (bounds.width - self.bounds.width).abs() > 0.5
389            || (bounds.height - self.bounds.height).abs() > 0.5
390        {
391            self.cache.invalidate();
392        }
393        self.bounds = bounds;
394    }
395
396    fn children(&self) -> &[Box<dyn Widget>] {
397        &self.children
398    }
399
400    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
401        &mut self.children
402    }
403
404    fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
405        Some(&mut self.cache)
406    }
407
408    fn backbuffer_mode(&self) -> crate::widget::BackbufferMode {
409        // Mirror Label: when the global LCD toggle is on (which the
410        // default rule wires to "scale ≤ 1.25"), use the per-channel LCD
411        // coverage cache so text on the bar is subpixel-rendered exactly
412        // like Label text.  Falls back to RGBA at HiDPI where LCD gains
413        // nothing.  MenuBar paints `top_bar_bg` as an opaque full-width
414        // fill before any other content, satisfying LcdCoverage's
415        // "widget must cover its bounds with opaque content" contract.
416        if crate::font_settings::lcd_enabled() {
417            crate::widget::BackbufferMode::LcdCoverage
418        } else {
419            crate::widget::BackbufferMode::Rgba
420        }
421    }
422
423    fn layout(&mut self, available: Size) -> Size {
424        // Keep the bar's Label cache in lock-step with `self.menus`
425        // before measuring — handles dynamic menu lists.
426        self.sync_bar_labels();
427        match self.orientation {
428            MenuOrientation::Horizontal | MenuOrientation::HorizontalBottom => {
429                let mut x = 0.0;
430                for menu in &mut self.menus {
431                    let width = (menu.label.chars().count() as f64 * 8.0 + 22.0).max(52.0);
432                    menu.rect = Rect::new(x, 0.0, width, BAR_H);
433                    x += width;
434                }
435                // Fit-width mode leaves room for sibling chrome.
436                let report_w = if self.fit_width { x } else { available.width };
437                Size::new(report_w, BAR_H)
438            }
439            MenuOrientation::Vertical => {
440                // Stack top-to-bottom in Y-up coordinates.
441                let mut y = available.height;
442                for menu in &mut self.menus {
443                    y -= VERTICAL_ROW_H;
444                    menu.rect = Rect::new(0.0, y, available.width, VERTICAL_ROW_H);
445                }
446                let used_h = self.menus.len() as f64 * VERTICAL_ROW_H;
447                Size::new(available.width, used_h)
448            }
449        }
450    }
451
452    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
453        // Re-sync in case paint runs without a preceding layout.
454        self.sync_bar_labels();
455        ctx.set_font(self.active_font());
456        ctx.set_font_size(self.font_size);
457        let v = ctx.visuals();
458        ctx.set_fill_color(v.top_bar_bg);
459        ctx.begin_path();
460        let bg_h = match self.orientation {
461            MenuOrientation::Horizontal | MenuOrientation::HorizontalBottom => BAR_H,
462            MenuOrientation::Vertical => self.bounds.height,
463        };
464        ctx.rect(0.0, 0.0, self.bounds.width, bg_h);
465        ctx.fill();
466        // First pass: button chrome under the text.
467        for (idx, menu) in self.menus.iter().enumerate() {
468            // After a click-to-close-toggle, the cursor is still over
469            // the bar item so `hover_index` still points at it —
470            // suppress the hover highlight until the cursor moves off
471            // and back on, so the closed menu doesn't read as "still
472            // selected".
473            let hovered = self.hover_index == Some(idx) && self.suppress_hover_for != Some(idx);
474            let open = self.open_index == Some(idx);
475            paint_menu_bar_button_bg(ctx, menu.rect, open, hovered);
476        }
477        // Second pass: paint each button's `Label` through
478        // `paint_subtree` so glyphs flow through Label's backbuffer +
479        // LCD path.  Done after backgrounds so the text composites on
480        // top of the hover fill.
481        let menu_rects: Vec<(Rect, bool)> = self
482            .menus
483            .iter()
484            .enumerate()
485            .map(|(idx, menu)| (menu.rect, self.open_index == Some(idx)))
486            .collect();
487        for (idx, (rect, open)) in menu_rects.into_iter().enumerate() {
488            let color = bar_button_text_color(ctx, open);
489            self.bar_labels.paint_in(ctx, idx, rect, color);
490        }
491    }
492
493    fn hit_test_global_overlay(&self, _local_pos: Point) -> bool {
494        self.popup.is_open()
495    }
496
497    fn has_active_modal(&self) -> bool {
498        self.popup.is_open()
499    }
500
501    fn on_event(&mut self, event: &Event) -> EventResult {
502        if let Event::MouseMove { pos } = event {
503            let hovered = self.menu_at(*pos);
504            self.set_hover_index(hovered);
505            // Hover-switch a different open menu while a popup is open.
506            // Suppressed when this MouseMove was synthesised by the
507            // touch shell from a touchstart — on mobile the synth move
508            // arrives at the tap position immediately followed by a
509            // synth MouseDown at the same point; switching the open
510            // menu here would make that MouseDown look like a click on
511            // the currently-open menu and toggle-close the popup the
512            // user just tapped to open.  On desktop the
513            // `last_touch_event_age` is `None` (or very large), so
514            // hover-switch works as before.
515            let from_touch = is_touch_synthesized();
516            if self.popup.is_open() && !from_touch {
517                if let Some(idx) = hovered {
518                    if self.open_index != Some(idx) {
519                        let activate_on_release = self.popup.state.is_mouse_up_activation_armed();
520                        self.open_menu(idx);
521                        if activate_on_release {
522                            self.popup.state.arm_mouse_up_activation();
523                        }
524                    }
525                    return EventResult::Consumed;
526                }
527            }
528        }
529        if self.popup.is_open() {
530            if let Event::KeyDown { key, .. } = event {
531                if self.should_switch_top_menu(key) {
532                    return match key {
533                        Key::ArrowLeft => self.switch_open_menu(-1),
534                        Key::ArrowRight => self.switch_open_menu(1),
535                        _ => EventResult::Ignored,
536                    };
537                }
538            }
539            // Tap-to-switch: when one menu is already open and a
540            // MouseDown lands on a DIFFERENT top menu's bar, switch
541            // directly.  Without this, the popup handler would see the
542            // MouseDown as outside-the-popup-body and close the menu,
543            // leaving the user staring at an empty bar.  Clicking the
544            // currently-open menu falls through to the popup so it can
545            // close (toggle, the desktop convention).
546            if let Event::MouseDown {
547                pos,
548                button: MouseButton::Left,
549                ..
550            } = event
551            {
552                if let Some(idx) = self.menu_at(*pos) {
553                    if self.open_index != Some(idx) {
554                        self.open_menu(idx);
555                        return EventResult::Consumed;
556                    }
557                }
558            }
559            // Drag-release in neutral space cancels.  The user pressed
560            // a top menu, dragged off both the bar and the popup body,
561            // and let go — the standard menu convention is to dismiss.
562            // The popup state's drag-release handler treats outside-
563            // popup-body as a no-op (so a mouse-up still on the bar
564            // doesn't close), so the bar enforces the cancel here
565            // since only the bar knows where its own top-menu rects
566            // live.
567            if let Event::MouseUp {
568                pos,
569                button: MouseButton::Left,
570                ..
571            } = event
572            {
573                if self.popup.state.is_mouse_up_activation_armed()
574                    && self.menu_at(*pos).is_none()
575                    && !self.popup.body_contains(*pos, current_viewport())
576                {
577                    self.popup.close();
578                    self.open_index = None;
579                    self.cache.invalidate();
580                    crate::animation::request_draw();
581                    return EventResult::Consumed;
582                }
583            }
584            let (result, response) = self.popup.handle_event(event, current_viewport());
585            if let MenuResponse::Action(action) = response {
586                if let Some(idx) = self.open_index {
587                    self.menus[idx].items = self.popup.items.clone();
588                }
589                (self.on_action)(&action);
590                if !self.popup.is_open() {
591                    self.open_index = None;
592                    self.cache.invalidate();
593                }
594            } else if matches!(response, MenuResponse::Closed) {
595                self.open_index = None;
596                // Suppress the hover highlight on the menu the cursor
597                // is still over — without this, click-to-close-toggle
598                // leaves the bar item painted in the hover tint and
599                // reads as "still selected".  Cleared once the cursor
600                // moves to a different top-menu (or off the bar).
601                self.suppress_hover_for = self.hover_index;
602                self.cache.invalidate();
603            }
604            if result == EventResult::Consumed {
605                return result;
606            }
607        }
608        match event {
609            Event::MouseDown {
610                pos,
611                button: MouseButton::Left,
612                ..
613            } => {
614                if let Some(idx) = self.menu_at(*pos) {
615                    self.open_menu_for_drag_release(idx);
616                    EventResult::Consumed
617                } else {
618                    EventResult::Ignored
619                }
620            }
621            Event::MouseMove { .. } => EventResult::Ignored,
622            _ => EventResult::Ignored,
623        }
624    }
625
626    fn on_unconsumed_key(&mut self, key: &Key, modifiers: Modifiers) -> EventResult {
627        let response = if self.popup.is_open() {
628            self.popup.handle_shortcut(key, modifiers)
629        } else {
630            self.menus
631                .iter_mut()
632                .find_map(|menu| {
633                    let mut popup = PopupMenu::new(menu.items.clone());
634                    match popup.handle_shortcut(key, modifiers) {
635                        MenuResponse::Action(action) => {
636                            menu.items = popup.items;
637                            Some(action)
638                        }
639                        MenuResponse::None | MenuResponse::Closed => None,
640                    }
641                })
642                .map(MenuResponse::Action)
643                .unwrap_or(MenuResponse::None)
644        };
645        if let MenuResponse::Action(action) = response {
646            if let Some(idx) = self.open_index {
647                self.menus[idx].items = self.popup.items.clone();
648            }
649            (self.on_action)(&action);
650            if !self.popup.is_open() {
651                self.open_index = None;
652                self.cache.invalidate();
653            }
654            EventResult::Consumed
655        } else {
656            EventResult::Ignored
657        }
658    }
659
660    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
661        let font = self.active_font();
662        self.popup
663            .paint(ctx, font, self.font_size, current_viewport());
664    }
665}
666
667/// Paint one popup panel: hover backgrounds, separators, icons,
668/// check / radio marks, submenu chevrons, AND each row's text via the
669/// shared `PopupLabels` cache.  Text colour is resolved per-row from
670/// the item's enabled / open / hovered state so a hover flip retints
671/// just one Label.
672fn paint_popup_level(
673    ctx: &mut dyn DrawCtx,
674    level_idx: usize,
675    layout: &super::geometry::PopupLayout,
676    items: &[MenuEntry],
677    state: &PopupMenuState,
678    style: &MenuStyle,
679    labels: &mut PopupLabels,
680) {
681    let level_items = items_for_layout(items, &layout.path_prefix);
682    for (row_idx, row_layout) in layout.rows.iter().enumerate() {
683        let Some(item_idx) = row_layout.item_index else {
684            // Separator row.
685            paint_separator(ctx, row_layout.rect);
686            continue;
687        };
688        let Some(MenuEntry::Item(item)) = level_items.get(item_idx) else {
689            continue;
690        };
691        let mut path = layout.path_prefix.clone();
692        path.push(item_idx);
693        let hovered = state.hover_path.as_ref() == Some(&path);
694        let open = state.open_path.starts_with(&path);
695
696        // Hover / open backdrop sits underneath the row's content.
697        paint_item_row_bg(ctx, row_layout.rect, hovered, open, item.enabled);
698
699        // Inline glyphs (icon / check / radio) — single chars that
700        // change infrequently; keeping them direct `fill_text` avoids
701        // a Label per glyph at the cost of a single rasterise call.
702        let inline_color =
703            super::paint::popup_row_text_color(ctx, item.enabled, open && item.enabled);
704        ctx.set_fill_color(inline_color);
705        if let Some(color) = item.swatch {
706            // Colour-swatch row (e.g., an accent palette).  A small
707            // rounded filled rect in the icon slot, anchored
708            // vertically to the row centre.  The selected radio gets a
709            // thin stroke around the swatch instead of the usual radio
710            // glyph so the colour itself stays the dominant cue.
711            let size = 12.0;
712            let sx = row_layout.rect.x + style.icon_x - size * 0.5 + 4.0;
713            let sy = row_layout.rect.y + (row_layout.rect.height - size) * 0.5;
714            let fill = if item.enabled {
715                color
716            } else {
717                // Wash out disabled swatches so they read as "not
718                // pickable" without losing their identity.
719                color.with_alpha(0.45)
720            };
721            ctx.set_fill_color(fill);
722            ctx.begin_path();
723            ctx.rounded_rect(sx, sy, size, size, 3.0);
724            ctx.fill();
725            if matches!(item.selection, MenuSelection::Radio { selected: true }) {
726                ctx.set_stroke_color(inline_color);
727                ctx.set_line_width(1.5);
728                ctx.begin_path();
729                ctx.rounded_rect(sx - 2.0, sy - 2.0, size + 4.0, size + 4.0, 4.0);
730                ctx.stroke();
731            }
732        } else if let Some(icon) = item.icon {
733            let icon = icon.to_string();
734            ctx.fill_text(
735                &icon,
736                row_layout.rect.x + style.icon_x,
737                row_layout.rect.y + 7.0,
738            );
739        }
740        // Selection indicator. Vector-drawn check mark shared by both
741        // Check and Radio rows so the indicator looks identical
742        // regardless of the host's icon font (or lack of one). When
743        // the row already has a left-side marker (icon or swatch),
744        // the check paints at the right edge so the marker's
745        // identity stays visible; otherwise it occupies the icon
746        // slot.
747        let selected = matches!(
748            item.selection,
749            MenuSelection::Check { selected: true } | MenuSelection::Radio { selected: true }
750        );
751        if selected {
752            let has_left_marker = item.swatch.is_some() || item.icon.is_some();
753            let cx = if has_left_marker {
754                let right_offset = if item.has_submenu() { 30.0 } else { 12.0 };
755                row_layout.rect.x + row_layout.rect.width - right_offset
756            } else {
757                row_layout.rect.x + style.icon_x
758            };
759            let cy = row_layout.rect.y + row_layout.rect.height * 0.5;
760            paint_check_mark(ctx, cx, cy, inline_color);
761        }
762        if item.has_submenu() {
763            paint_submenu_chevron(ctx, row_layout.rect, inline_color);
764        }
765
766        // Row text (label + shortcut) via the Label cache so glyph
767        // rendering routes through the backbuffer + LCD path.
768        labels.paint_row_with_state(
769            ctx,
770            level_idx,
771            row_idx,
772            row_layout.rect,
773            style.label_x,
774            style.shortcut_right,
775            item.enabled,
776            open && item.enabled,
777        );
778    }
779}
780
781fn items_for_layout<'a>(items: &'a [MenuEntry], path: &[usize]) -> &'a [MenuEntry] {
782    let mut current = items;
783    for &idx in path {
784        let Some(MenuEntry::Item(item)) = current.get(idx) else {
785            return current;
786        };
787        current = &item.submenu;
788    }
789    current
790}
791
792#[cfg(test)]
793mod tests_1;
794#[cfg(test)]
795mod tests_2;