Skip to main content

agg_gui/widgets/menu/
paint.rs

1//! Themed painting for popup menus and menu bars.
2//!
3//! The painter deliberately sets every fill and stroke it uses so menu colors
4//! never inherit accidental state from the widget that opened the popup.
5
6use std::sync::Arc;
7
8use crate::color::Color;
9use crate::draw_ctx::DrawCtx;
10use crate::geometry::Rect;
11use crate::text::Font;
12
13use super::geometry::{PopupLayout, SEP_H};
14use super::model::{MenuEntry, MenuSelection};
15use super::state::PopupMenuState;
16
17#[derive(Clone)]
18pub struct MenuStyle {
19    pub radius: f64,
20    pub shadow_offset: (f64, f64),
21    pub shadow_alpha: f32,
22    pub pad_x: f64,
23    pub icon_x: f64,
24    pub label_x: f64,
25    pub shortcut_right: f64,
26}
27
28impl Default for MenuStyle {
29    fn default() -> Self {
30        Self {
31            radius: 5.0,
32            shadow_offset: (5.0, -5.0),
33            shadow_alpha: 0.22,
34            pad_x: 8.0,
35            icon_x: 14.0,
36            label_x: 32.0,
37            shortcut_right: 28.0,
38        }
39    }
40}
41
42pub fn paint_popup_stack(
43    ctx: &mut dyn DrawCtx,
44    font: Arc<Font>,
45    font_size: f64,
46    items: &[MenuEntry],
47    state: &PopupMenuState,
48    layouts: &[PopupLayout],
49    style: &MenuStyle,
50) {
51    ctx.set_font(font);
52    ctx.set_font_size(font_size);
53    for layout in layouts {
54        paint_panel(ctx, layout.rect, style);
55        for (entry, row) in items_for_layout(items, &layout.path_prefix)
56            .iter()
57            .zip(&layout.rows)
58        {
59            match entry {
60                MenuEntry::Separator => paint_separator(ctx, row.rect),
61                MenuEntry::Item(item) => {
62                    let mut path = layout.path_prefix.clone();
63                    path.push(row.item_index.unwrap_or_default());
64                    let hovered = state.hover_path.as_ref() == Some(&path);
65                    let open = state.open_path.starts_with(&path);
66                    paint_item_row(ctx, row.rect, item, hovered, open, style);
67                }
68            }
69        }
70    }
71}
72
73pub fn paint_menu_bar_button(
74    ctx: &mut dyn DrawCtx,
75    rect: Rect,
76    label: &str,
77    open: bool,
78    hovered: bool,
79) {
80    let v = ctx.visuals();
81    // Subtle desktop hover: a translucent accent tint under the label, not
82    // the full accent.  Translucent because the underlying `top_bar_bg` is
83    // already a very light gray (≈0.88 in the light theme); a fully-opaque
84    // `widget_bg_hovered` panel reads as nothing — `widget_bg_hovered` is
85    // only ~0.04 brighter than the bar.  Translucent accent stays visible
86    // on either theme while reserving the FULL accent for the OPENED state.
87    if open || hovered {
88        let bg = if open {
89            v.accent
90        } else {
91            v.accent.with_alpha(0.18)
92        };
93        ctx.set_fill_color(bg);
94        ctx.begin_path();
95        ctx.rounded_rect(
96            rect.x + 1.0,
97            rect.y + 2.0,
98            rect.width - 2.0,
99            rect.height - 4.0,
100            4.0,
101        );
102        ctx.fill();
103    }
104    ctx.set_fill_color(if open { Color::white() } else { v.text_color });
105    ctx.fill_text(label, rect.x + 9.0, rect.y + 7.0);
106}
107
108fn paint_panel(ctx: &mut dyn DrawCtx, rect: Rect, style: &MenuStyle) {
109    let v = ctx.visuals();
110    ctx.set_fill_color(Color::black().with_alpha(style.shadow_alpha));
111    ctx.begin_path();
112    ctx.rounded_rect(
113        rect.x + style.shadow_offset.0,
114        rect.y + style.shadow_offset.1,
115        rect.width,
116        rect.height,
117        style.radius,
118    );
119    ctx.fill();
120
121    ctx.set_fill_color(v.panel_fill);
122    ctx.begin_path();
123    ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.radius);
124    ctx.fill();
125    ctx.set_stroke_color(v.widget_stroke);
126    ctx.set_line_width(1.0);
127    ctx.begin_path();
128    ctx.rounded_rect(rect.x, rect.y, rect.width, rect.height, style.radius);
129    ctx.stroke();
130}
131
132fn paint_separator(ctx: &mut dyn DrawCtx, rect: Rect) {
133    let v = ctx.visuals();
134    ctx.set_stroke_color(v.widget_stroke.with_alpha(0.55));
135    ctx.set_line_width(1.0);
136    ctx.begin_path();
137    ctx.move_to(rect.x + 8.0, rect.y + SEP_H * 0.5);
138    ctx.line_to(rect.x + rect.width - 8.0, rect.y + SEP_H * 0.5);
139    ctx.stroke();
140}
141
142fn paint_item_row(
143    ctx: &mut dyn DrawCtx,
144    rect: Rect,
145    item: &super::model::MenuItem,
146    hovered: bool,
147    open: bool,
148    style: &MenuStyle,
149) {
150    let v = ctx.visuals();
151    let hovered = hovered && item.enabled;
152    let open = open && item.enabled;
153    // Same subtle/strong split as `paint_menu_bar_button`: hover hints
154    // with a translucent accent tint; open commits with full accent.
155    if hovered || open {
156        let bg = if open {
157            v.accent
158        } else {
159            v.accent.with_alpha(0.18)
160        };
161        ctx.set_fill_color(bg);
162        ctx.begin_path();
163        ctx.rounded_rect(
164            rect.x + 3.0,
165            rect.y + 2.0,
166            rect.width - 6.0,
167            rect.height - 4.0,
168            3.0,
169        );
170        ctx.fill();
171    }
172
173    let text_color = if !item.enabled {
174        v.text_color.with_alpha(0.45)
175    } else if open {
176        Color::white()
177    } else {
178        v.text_color
179    };
180    ctx.set_fill_color(text_color);
181    if let Some(icon) = item.icon {
182        let icon = icon.to_string();
183        ctx.fill_text(&icon, rect.x + style.icon_x, rect.y + 7.0);
184    } else {
185        match item.selection {
186            MenuSelection::Check { selected: true } => {
187                ctx.fill_text("\u{f00c}", rect.x + style.icon_x, rect.y + 7.0);
188            }
189            MenuSelection::Radio { selected: true } => {
190                ctx.fill_text("\u{f111}", rect.x + style.icon_x, rect.y + 7.0);
191            }
192            MenuSelection::None
193            | MenuSelection::Check { selected: false }
194            | MenuSelection::Radio { selected: false } => {}
195        }
196    }
197    ctx.fill_text(&item.label, rect.x + style.label_x, rect.y + 7.0);
198    if let Some(shortcut) = &item.shortcut {
199        let width = ctx
200            .measure_text(shortcut)
201            .map(|metrics| metrics.width)
202            .unwrap_or(0.0);
203        ctx.fill_text(
204            shortcut,
205            rect.x + rect.width - style.shortcut_right - width,
206            rect.y + 7.0,
207        );
208    }
209    if item.has_submenu() {
210        ctx.fill_text("\u{f105}", rect.x + rect.width - 18.0, rect.y + 7.0);
211    }
212}
213
214fn items_for_layout<'a>(items: &'a [MenuEntry], path: &[usize]) -> &'a [MenuEntry] {
215    let mut current = items;
216    for &idx in path {
217        let Some(MenuEntry::Item(item)) = current.get(idx) else {
218            return current;
219        };
220        current = &item.submenu;
221    }
222    current
223}