Skip to main content

rosace_widgets/tree/
dropdown.rs

1use std::sync::Arc;
2use rosace_core::types::{Point, Rect, Size};
3use rosace_state::Atom;
4use super::{Widget, LayoutCtx, PaintCtx};
5use super::overlay::{OverlayEntry, LayerPosition, InputBehavior, FocusBehavior, ScrimConfig, push_overlay};
6use super::menu::Menu;
7use rosace_render::Color;
8
9/// A select control: a trigger showing the current option; tapping opens a
10/// Menu of options below it; choosing one calls `on_change(index)`.
11pub struct Dropdown {
12    options: Vec<String>,
13    selected: usize,
14    open: Atom<bool>,
15    disabled: bool,
16    width: f32,
17    background: Option<Color>,
18    color: Option<Color>,
19    border_color: Option<Color>,
20    border_width: f32,
21    radius: f32,
22    on_change: Option<Arc<dyn Fn(usize) + Send + Sync>>,
23}
24
25impl Dropdown {
26    pub fn new(options: Vec<impl Into<String>>, selected: usize, open: Atom<bool>) -> Self {
27        Self {
28            options: options.into_iter().map(Into::into).collect(), selected, open, disabled: false, width: 200.0,
29            background: None, color: None, border_color: None, border_width: 1.0, radius: 8.0,
30            on_change: None,
31        }
32    }
33    pub fn width(mut self, w: f32) -> Self { self.width = w; self }
34    pub fn disabled(mut self) -> Self { self.disabled = true; self }
35    /// Trigger fill color (theme's `surface_variant` if unset).
36    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
37    /// Label/chevron color (theme's `on_surface` if unset).
38    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
39    pub fn border(mut self, c: Color, w: f32) -> Self { self.border_color = Some(c); self.border_width = w; self }
40    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
41    pub fn on_change(mut self, f: impl Fn(usize) + Send + Sync + 'static) -> Self {
42        self.on_change = Some(Arc::new(f)); self
43    }
44}
45
46impl Widget for Dropdown {
47    fn layout(&self, ctx: &LayoutCtx) -> Size {
48        ctx.constraints.constrain(Size { width: self.width, height: 36.0 })
49    }
50    fn paint(&self, ctx: &mut PaintCtx) {
51        let selected_label = self.options.get(self.selected).map(|s| s.as_str()).unwrap_or("");
52        // The trigger is a button that opens a menu — its own MenuItem
53        // children (via Menu, which already declares semantics) carry the
54        // option list; this is just the current-selection summary.
55        ctx.semantics(super::Semantics::new(rosace_core::Role::Button).label(selected_label));
56        let (bg, fg, border) = {
57            let t = &ctx.theme.colors;
58            (self.background.unwrap_or_else(|| ctx.tc(t.surface_variant)),
59             self.color.unwrap_or_else(|| ctx.tc(t.on_surface)),
60             self.border_color.unwrap_or_else(|| ctx.tc(t.outline)))
61        };
62        let is_open = self.open.get();
63        let focused = !self.disabled && ctx.focus_node().is_focused();
64        let hovered = !self.disabled && ctx.hovered();
65        let pressed = !self.disabled && ctx.pressed();
66        let wash = ctx.animate_channel(0, if pressed { 0.10 } else if hovered { 0.05 } else { 0.0 }, 0.0);
67        let dim = if self.disabled { 0.45 } else { 1.0 };
68        let with_alpha = |c: Color, a: f32| Color::rgba(c.r, c.g, c.b, ((c.a as f32 / 255.0) * a.clamp(0.0, 1.0) * 255.0).round() as u8);
69
70        let r = ctx.rect;
71        let mut bg = bg;
72        if wash > 0.001 { bg = super::lerp_color(bg, Color::rgb(255, 255, 255), wash); }
73        ctx.fill_rrect(r, self.radius, with_alpha(bg, dim));
74        // Focus/open border brightens toward the accent.
75        let ring = if focused || is_open { ctx.tc(ctx.theme.colors.primary) } else { border };
76        ctx.stroke_rrect(r, self.radius, with_alpha(ring, dim), if focused || is_open { 1.5 } else { self.border_width });
77        let lh = ctx.font.line_height(13.0);
78        ctx.draw_text_at(selected_label, Point { x: r.origin.x + 12.0, y: r.origin.y + (r.size.height - lh) / 2.0 }, with_alpha(fg, dim), 13.0);
79        // Chevron flips down/up when open. A real Icon (bundled Material
80        // Symbols font, baked into the binary) instead of a raw Unicode
81        // ▾/▴ character drawn through the body-text font — that font
82        // (Inter) has no glyph for it, which rendered as a garbled/tofu
83        // box on Android (no OS-level font-fallback there, unlike desktop).
84        let chev_kind = if is_open { super::IconKind::ChevronUp } else { super::IconKind::ChevronDown };
85        let chev = super::Icon::new(chev_kind).size(14.0).color(with_alpha(fg, dim));
86        let cy = r.origin.y + (r.size.height - chev.size) / 2.0;
87        chev.paint(&mut ctx.child(Rect {
88            origin: Point { x: r.origin.x + r.size.width - chev.size - 8.0, y: cy },
89            size: Size { width: chev.size, height: chev.size },
90        }));
91
92        if !self.disabled {
93            let open = self.open.clone();
94            // Toggle, not just open — the trigger doubles as its own
95            // close button once the menu is showing (see the scrim's
96            // `exclude_rect` below, which keeps outside-tap dismiss from
97            // fighting this on the same click).
98            ctx.register_hit(Arc::new(move || open.set(!open.get())));
99        } else {
100            ctx.register_hit(Arc::new(|| {}));
101        }
102
103        if self.open.get() {
104            let pos = Point { x: r.origin.x, y: r.origin.y + r.size.height + 4.0 };
105            let mut menu = Menu::new().min_width(self.width);
106            for (i, opt) in self.options.iter().enumerate() {
107                let open = self.open.clone();
108                let cb = self.on_change.clone();
109                menu = menu.item(opt.clone(), move || {
110                    open.set(false);
111                    if let Some(cb) = &cb { cb(i); }
112                });
113            }
114            let open2 = self.open.clone();
115            push_overlay(
116                OverlayEntry::new(LayerPosition::Absolute(pos), menu)
117                    .input(InputBehavior::PassThrough)
118                    .focus(FocusBehavior::PassThrough)
119                    .scrim(ScrimConfig {
120                        color: Color::TRANSPARENT,
121                        on_tap: Some(Arc::new(move || open2.set(false))),
122                        // Own trigger's rect — a click there is handled by
123                        // this widget's `register_hit` toggle above, not
124                        // by outside-tap dismiss.
125                        exclude_rect: Some(r),
126                    }),
127            );
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use rosace_layout::Constraints;
136
137    #[test]
138    fn customization_builders_do_not_change_layout_size() {
139        let font = rosace_render::FontCache::embedded();
140        let theme = rosace_theme::built_in::dark_theme();
141        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
142        let open = Atom::new(rosace_state::next_atom_id(), false);
143        let dd = Dropdown::new(vec!["A", "B"], 0, open)
144            .background(Color::rgb(10, 10, 10))
145            .color(Color::rgb(255, 255, 255))
146            .border(Color::rgb(200, 0, 0), 2.0)
147            .radius(4.0);
148        let size = dd.layout(&ctx);
149        assert_eq!((size.width, size.height), (200.0, 36.0));
150    }
151}