Skip to main content

rosace_widgets/tree/
segmented.rs

1use std::sync::Arc;
2use rosace_core::types::{Point, Rect, Size};
3use rosace_render::Color;
4use super::{Widget, LayoutCtx, PaintCtx};
5
6/// A one-of-N horizontal selector — segments in a rounded track, the selected
7/// one highlighted. `on_change(index)` fires on tap.
8pub struct SegmentedControl {
9    segments: Vec<String>,
10    selected: usize,
11    disabled: bool,
12    height: f32,
13    track_color: Option<Color>,
14    selected_color: Option<Color>,
15    color: Option<Color>,
16    selected_text_color: Option<Color>,
17    on_change: Option<Arc<dyn Fn(usize) + Send + Sync>>,
18}
19
20impl SegmentedControl {
21    pub fn new(segments: Vec<impl Into<String>>, selected: usize) -> Self {
22        Self {
23            segments: segments.into_iter().map(Into::into).collect(), selected, disabled: false, height: 34.0,
24            track_color: None, selected_color: None, color: None, selected_text_color: None,
25            on_change: None,
26        }
27    }
28    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
29    pub fn disabled(mut self) -> Self { self.disabled = true; self }
30    /// Track (unselected background) color — theme's `surface_variant` if unset.
31    pub fn track_color(mut self, c: Color) -> Self { self.track_color = Some(c); self }
32    /// Selected pill's fill color — theme's `primary` if unset.
33    pub fn selected_color(mut self, c: Color) -> Self { self.selected_color = Some(c); self }
34    /// Unselected label color — theme's `on_surface` if unset.
35    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
36    /// Selected label color — theme's `on_primary` if unset.
37    pub fn selected_text_color(mut self, c: Color) -> Self { self.selected_text_color = Some(c); self }
38    pub fn on_change(mut self, f: impl Fn(usize) + Send + Sync + 'static) -> Self {
39        self.on_change = Some(Arc::new(f)); self
40    }
41}
42
43impl Widget for SegmentedControl {
44    fn layout(&self, ctx: &LayoutCtx) -> Size {
45        let w: f32 = self.segments.iter()
46            .map(|s| ctx.font.measure_text(s, 13.0) + 28.0)
47            .sum();
48        ctx.constraints.constrain(Size { width: w.max(120.0), height: self.height })
49    }
50    fn paint(&self, ctx: &mut PaintCtx) {
51        let r = ctx.rect;
52        let n = self.segments.len().max(1);
53        let seg_w = r.size.width / n as f32;
54        let radius = self.height / 2.0;
55        let (track, fg, sel_bg, sel_fg) = {
56            let t = &ctx.theme.colors;
57            (self.track_color.unwrap_or_else(|| ctx.tc(t.surface_variant)),
58             self.color.unwrap_or_else(|| ctx.tc(t.on_surface)),
59             self.selected_color.unwrap_or_else(|| ctx.tc(t.primary)),
60             self.selected_text_color.unwrap_or_else(|| ctx.tc(t.on_primary)))
61        };
62        let dim = if self.disabled { 0.45 } else { 1.0 };
63        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);
64        let focused = !self.disabled && ctx.focus_node().is_focused();
65
66        // Track
67        ctx.fill_rrect(r, radius, with_alpha(track, dim));
68
69        // The highlight pill slides between segments (eased index position).
70        let pos = ctx.animate_to(self.selected as f32, 0.0);
71        let px = r.origin.x + pos * seg_w;
72        let pill = Rect {
73            origin: Point { x: px + 3.0, y: r.origin.y + 3.0 },
74            size: Size { width: seg_w - 6.0, height: r.size.height - 6.0 },
75        };
76        ctx.fill_rrect(pill, radius - 3.0, with_alpha(sel_bg, dim));
77
78        for (i, label) in self.segments.iter().enumerate() {
79            let x = r.origin.x + i as f32 * seg_w;
80            let seg_rect = Rect { origin: Point { x, y: r.origin.y }, size: Size { width: seg_w, height: r.size.height } };
81            let mut child = ctx.child(seg_rect);
82
83            // Per-segment hover/press wash on the UNSELECTED segments (the
84            // selected one already reads as active via the pill).
85            let hov = !self.disabled && child.hovered();
86            let prs = !self.disabled && child.pressed();
87            let nearness = (1.0 - (pos - i as f32).abs()).clamp(0.0, 1.0);
88            if (hov || prs) && nearness < 0.5 {
89                let wash = if prs { 0.12 } else { 0.07 };
90                child.fill_rrect(Rect {
91                    origin: Point { x: x + 3.0, y: r.origin.y + 3.0 },
92                    size: Size { width: seg_w - 6.0, height: r.size.height - 6.0 },
93                }, radius - 3.0, with_alpha(Color::rgb(255, 255, 255), wash));
94            }
95
96            let tw = child.font.measure_text(label, 13.0);
97            let lh = child.font.line_height(13.0);
98            let tx = x + (seg_w - tw) / 2.0;
99            let ty = r.origin.y + (r.size.height - lh) / 2.0;
100            // Text color blends toward selected as the pill nears this segment.
101            let col = super::lerp_color(fg, sel_fg, nearness);
102            child.draw_text_at(label, Point { x: tx, y: ty }, with_alpha(col, dim), 13.0);
103            child.semantics(
104                super::Semantics::new(rosace_core::Role::Tab)
105                    .label(label)
106                    .value(if i == self.selected { "selected" } else { "not selected" }),
107            );
108            match (&self.on_change, self.disabled) {
109                (Some(cb), false) => { let cb = cb.clone(); let idx = i; child.register_hit(Arc::new(move || cb(idx))); }
110                _ => child.register_hit(Arc::new(|| {})),
111            }
112        }
113
114        // Focus ring around the whole control.
115        if focused {
116            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);
117            ctx.stroke_rrect(r, radius, with_alpha(sel_bg, 0.9), 2.0);
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use rosace_layout::Constraints;
126
127    #[test]
128    fn customization_builders_do_not_change_layout_size() {
129        let font = rosace_render::FontCache::embedded();
130        let theme = rosace_theme::built_in::dark_theme();
131        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
132        let base = SegmentedControl::new(vec!["Day", "Week"], 0);
133        let customized = SegmentedControl::new(vec!["Day", "Week"], 0)
134            .track_color(Color::rgb(10, 10, 10))
135            .selected_color(Color::rgb(255, 0, 0))
136            .color(Color::rgb(255, 255, 255))
137            .selected_text_color(Color::rgb(0, 0, 0));
138        assert_eq!(base.layout(&ctx), customized.layout(&ctx));
139    }
140}