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
9pub 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 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
37 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 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 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 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 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 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}