Skip to main content

rosace_widgets/tree/
menu.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Rect, Size};
4use rosace_render::Color;
5use super::{Widget, LayoutCtx, PaintCtx};
6use super::container::draw_rounded_rect_pub;
7
8type Item = (String, Arc<dyn Fn() + Send + Sync>);
9
10/// A vertical list of pressable rows — the standard dropdown content.
11///
12/// Pair with [`OverlayApi::dropdown`], which anchors it below the trigger:
13///
14/// ```rust,ignore
15/// Button::new("File")
16///     .dropdown(open.clone(), move || Box::new(
17///         Menu::new()
18///             .item("New",  { let o = open.clone(); move || { o.set(false); /* … */ } })
19///             .item("Open", { let o = open.clone(); move || { o.set(false); /* … */ } })
20///     ))
21/// ```
22///
23/// [`OverlayApi::dropdown`]: super::overlay_api::OverlayApi::dropdown
24pub struct Menu {
25    items: Vec<Item>,
26    pub min_width: f32,
27    pub row_height: f32,
28    /// `None` = read from the active theme's `typography.body_medium`
29    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
30    /// for the reasoning).
31    pub font_size: Option<f32>,
32    pub radius: f32,
33    background: Option<Color>,
34    color: Option<Color>,
35}
36
37impl Menu {
38    pub fn new() -> Self {
39        Self {
40            items: Vec::new(),
41            min_width: 180.0,
42            row_height: 34.0,
43            font_size: None,
44            radius: 14.0,
45            background: None,
46            color: None,
47        }
48    }
49
50    pub fn min_width(mut self, w: f32) -> Self { self.min_width = w; self }
51    pub fn row_height(mut self, h: f32) -> Self { self.row_height = h; self }
52    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
53    /// Menu surface fill color (theme's `surface` if unset).
54    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
55    /// Item label color (theme's `on_surface` if unset).
56    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
57
58    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
59        self.font_size.unwrap_or(theme.typography.body_medium.size)
60    }
61
62    /// Append a pressable row. The callback fires on click; close the menu
63    /// yourself by setting the `open` atom false inside it.
64    pub fn item(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
65        self.items.push((label.into(), Arc::new(f)));
66        self
67    }
68}
69
70impl Default for Menu {
71    fn default() -> Self { Self::new() }
72}
73
74const PAD_V: f32 = 6.0;
75const PAD_H: f32 = 14.0;
76
77impl Widget for Menu {
78    fn layout(&self, ctx: &LayoutCtx) -> Size {
79        let font_size = self.resolved_font_size(ctx.theme);
80        let widest = self.items.iter()
81            .map(|(label, _)| ctx.font.measure_text(label, font_size))
82            .fold(0.0_f32, f32::max);
83        let width = (widest + PAD_H * 2.0).max(self.min_width);
84        let height = self.items.len() as f32 * self.row_height + PAD_V * 2.0;
85        ctx.constraints.constrain(Size { width, height })
86    }
87
88    fn paint(&self, ctx: &mut PaintCtx) {
89        let (bg, fg, outline) = {
90            let t = &ctx.theme.colors;
91            // Default panel is TRANSLUCENT (the overlay pass alpha-blends
92            // over the app) with a hairline border — a popup reads as part
93            // of the scene instead of an opaque slab punched over it
94            // (found live: the dropdown menu broke the liquid-glass app's
95            // whole look). Real backdrop-glass popups need overlay-pass
96            // shader-quad support — named, not built. An explicit
97            // `.background()` opts out entirely.
98            let default_bg = {
99                let s = ctx.tc(t.surface);
100                Color { r: s.r, g: s.g, b: s.b, a: 216 }
101            };
102            (self.background.unwrap_or(default_bg),
103             self.color.unwrap_or_else(|| ctx.tc(t.on_surface)),
104             ctx.tc(t.outline))
105        };
106        let r = ctx.rect;
107        ctx.fill_shadow_rrect(r, self.radius, Color::rgba(0, 0, 0, 90), 10.0);
108        draw_rounded_rect_pub(ctx, r, bg, self.radius);
109        ctx.stroke_rrect(r, self.radius, Color { a: 120, ..outline }, 1.0);
110        let font_size = self.resolved_font_size(&ctx.theme);
111        let line_h = ctx.font.line_height(font_size);
112        let hi = ctx.tc(ctx.theme.colors.on_surface);
113        let with_alpha = |c: Color, a: f32| Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8);
114
115        for (i, (label, cb)) in self.items.iter().enumerate() {
116            let row = Rect {
117                origin: rosace_core::types::Point {
118                    x: r.origin.x,
119                    y: r.origin.y + PAD_V + i as f32 * self.row_height,
120                },
121                size: Size { width: r.size.width, height: self.row_height },
122            };
123            // Child ctx per row — hit rect is clip-aware AND hover/press are
124            // tracked per-row so we can highlight the item under the pointer.
125            let mut child = ctx.child(row);
126            let hov = child.hovered();
127            let prs = child.pressed();
128            if hov || prs {
129                let inset = Rect {
130                    origin: rosace_core::types::Point { x: row.origin.x + 5.0, y: row.origin.y + 2.0 },
131                    size: Size { width: row.size.width - 10.0, height: row.size.height - 4.0 },
132                };
133                child.fill_rrect(inset, 8.0, with_alpha(hi, if prs { 0.16 } else { 0.09 }));
134            }
135            let ty = row.origin.y + (self.row_height - line_h) / 2.0;
136            child.draw_text_at(label, rosace_core::types::Point { x: row.origin.x + PAD_H, y: ty }, fg, font_size);
137            child.semantics(super::Semantics::new(rosace_core::Role::MenuItem).label(label));
138            child.register_hit(Arc::clone(cb));
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use rosace_layout::Constraints;
147
148    #[test]
149    fn customization_builders_do_not_change_layout_size() {
150        let font = rosace_render::FontCache::embedded();
151        let theme = rosace_theme::built_in::dark_theme();
152        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
153        let base = Menu::new().item("Item one", || {});
154        let customized = Menu::new()
155            .background(Color::rgb(20, 20, 20))
156            .color(Color::rgb(255, 255, 255))
157            .radius(2.0)
158            .item("Item one", || {});
159        assert_eq!(base.layout(&ctx), customized.layout(&ctx));
160    }
161}