Skip to main content

rosace_widgets/tree/
slider.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_render::Color;
5use super::{Widget, LayoutCtx, PaintCtx, avail_w};
6use super::container::draw_rounded_rect_pub;
7
8/// A horizontal slider — brought to the Quality Bar (matches the `Switch`
9/// exemplar; see `.steering/WIDGET_QUALITY_BAR.md`).
10///
11/// - **States** — idle · hover (thumb grows) · pressed/dragging (grows more) ·
12///   focus-visible (ring) · disabled (dimmed, inert).
13/// - **Motion** — the fill + thumb track the value *instantly* (they follow the
14///   finger, never lag); the thumb's grow and its hover/press/focus state-layer
15///   halo ease smoothly on their own channels.
16/// - **Elevation** — the bright knob casts a soft shadow over a rounded pill
17///   track with a colored fill.
18/// - **A11y** — `Role::Slider` with the current value; drag works through the
19///   engine's positional press (interactive-by-identity — an unwired slider
20///   still absorbs so it can't pan a scroll view behind it).
21pub struct Slider {
22    pub value: f32, // normalized 0..1
23    pub min: f32,
24    pub max: f32,
25    disabled: bool,
26    height: f32,
27    width: Option<f32>,
28    track_color: Option<Color>,
29    fill_color: Option<Color>,
30    thumb_color: Option<Color>,
31    on_change: Option<Arc<dyn Fn(f32) + Send + Sync>>,
32}
33
34impl Slider {
35    pub fn new(value: f32) -> Self {
36        Self {
37            value: value.clamp(0.0, 1.0),
38            min: 0.0,
39            max: 1.0,
40            disabled: false,
41            height: 24.0,
42            width: None,
43            track_color: None,
44            fill_color: None,
45            thumb_color: None,
46            on_change: None,
47        }
48    }
49    pub fn range(mut self, min: f32, max: f32, value: f32) -> Self {
50        self.min = min; self.max = max;
51        self.value = ((value - min) / (max - min)).clamp(0.0, 1.0);
52        self
53    }
54    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
55    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
56    pub fn disabled(mut self) -> Self { self.disabled = true; self }
57    pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
58    pub fn track_color(mut self, c: Color) -> Self { self.track_color = Some(c); self }
59    pub fn fill_color(mut self, c: Color) -> Self { self.fill_color = Some(c); self }
60    pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
61
62    /// Called with the new value (in `min..max`) on click or drag.
63    pub fn on_change(mut self, f: impl Fn(f32) + Send + Sync + 'static) -> Self {
64        self.on_change = Some(Arc::new(f));
65        self
66    }
67}
68
69fn with_alpha(c: Color, a: f32) -> Color {
70    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
71}
72
73impl Widget for Slider {
74    fn layout(&self, ctx: &LayoutCtx) -> Size {
75        Size { width: self.width.unwrap_or(avail_w(ctx.constraints)), height: self.height }
76    }
77
78    fn paint(&self, ctx: &mut PaintCtx) {
79        ctx.semantics(super::Semantics::new(rosace_core::Role::Slider)
80            .value(format!("{:.2}", self.min + self.value * (self.max - self.min))));
81
82        // ── Interactivity (identity) — a positional press is draggable via the
83        //     engine's active_drag. Disabled still absorbs. ────────────────────
84        let base_r = 9.0;
85        let r = ctx.rect;
86        let usable = (r.size.width - base_r * 2.0).max(1.0);
87        match (&self.on_change, self.disabled) {
88            (Some(f), false) => {
89                let f = f.clone();
90                let (min, max, x0) = (self.min, self.max, r.origin.x + base_r);
91                ctx.on_press_at(move |px, _| {
92                    let t = ((px - x0) / usable).clamp(0.0, 1.0);
93                    f(min + t * (max - min));
94                });
95            }
96            _ => ctx.on_press_at(|_, _| {}),
97        }
98        let focused = !self.disabled && ctx.focus_node().is_focused();
99        let hovered = !self.disabled && ctx.hovered();
100        let pressed = !self.disabled && ctx.pressed();
101
102        // ch0: hover/press/focus halo. ch1: thumb grow. (Position is NOT
103        // animated — it follows the value/finger instantly.)
104        let halo_t = if pressed { 0.18 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
105        let halo = ctx.animate_channel(0, halo_t, 0.0);
106        let grow = ctx.animate_channel(1, if pressed { 1.0 } else if hovered { 0.5 } else { 0.0 }, 0.0);
107        let thumb_r = base_r + grow * 2.0;
108
109        // ── Colors ────────────────────────────────────────────────────────────
110        let colors = ctx.theme.colors.clone();
111        let track = self.track_color.unwrap_or_else(|| ctx.tc(colors.surface_variant));
112        let fill = self.fill_color.unwrap_or_else(|| ctx.tc(colors.primary));
113        let thumb = self.thumb_color.unwrap_or_else(|| Color::rgb(250, 250, 252));
114        let shadow = ctx.tc(colors.shadow);
115        let dim = if self.disabled { 0.4 } else { 1.0 };
116
117        let cy = r.origin.y + r.size.height / 2.0;
118        let track_h = 6.0;
119        let tr = track_h / 2.0;
120        let cx = r.origin.x + base_r + usable * self.value;
121
122        // ── Track (rounded pill) + fill ──────────────────────────────────────
123        draw_rounded_rect_pub(ctx, Rect {
124            origin: Point { x: r.origin.x, y: cy - tr }, size: Size { width: r.size.width, height: track_h },
125        }, with_alpha(track, dim), tr);
126        let fill_w = cx - r.origin.x;
127        if fill_w > tr {
128            draw_rounded_rect_pub(ctx, Rect {
129                origin: Point { x: r.origin.x, y: cy - tr }, size: Size { width: fill_w, height: track_h },
130            }, with_alpha(fill, dim), tr);
131        }
132
133        // ── State-layer halo behind the thumb ────────────────────────────────
134        if halo > 0.001 {
135            ctx.fill_circle(Point { x: cx, y: cy }, thumb_r + 8.0, with_alpha(fill, halo));
136        }
137
138        // ── Thumb: elevation shadow then the bright knob ─────────────────────
139        let d = thumb_r * 2.0;
140        ctx.fill_shadow_rrect(
141            Rect { origin: Point { x: cx - thumb_r, y: cy - thumb_r + 1.0 }, size: Size { width: d, height: d } },
142            thumb_r, with_alpha(shadow, 0.3 * dim), 4.0,
143        );
144        ctx.fill_circle(Point { x: cx, y: cy }, thumb_r, with_alpha(thumb, dim));
145
146        // ── Focus ring ────────────────────────────────────────────────────────
147        if focused {
148            ctx.stroke_rrect(
149                Rect { origin: Point { x: cx - thumb_r - 3.0, y: cy - thumb_r - 3.0 }, size: Size { width: d + 6.0, height: d + 6.0 } },
150                thumb_r + 3.0, with_alpha(fill, 0.9), 2.0,
151            );
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use rosace_layout::Constraints;
160    use rosace_render::{FontCache, PictureRecorder};
161    use rosace_render::draw_command::DrawCommand;
162    use std::cell::RefCell;
163    use std::rc::Rc;
164    use crate::tree::RenderTree;
165
166    #[test]
167    fn customization_builders_do_not_change_layout_size() {
168        let font = rosace_render::FontCache::embedded();
169        let theme = rosace_theme::built_in::dark_theme();
170        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
171        let base = Slider::new(0.5);
172        let customized = Slider::new(0.5).height(30.0).fill_color(Color::rgb(255, 0, 0));
173        assert_eq!(base.layout(&ctx).width, customized.layout(&ctx).width);
174        assert_eq!(customized.layout(&ctx).height, 30.0);
175    }
176
177    #[test]
178    #[ignore] // SLIDER_PNG=/path cargo test -p rosace-widgets slider_showcase -- --ignored --nocapture
179    fn slider_showcase() {
180        use super::super::app::WidgetApp;
181        use super::super::Column;
182        use crate::EdgeInsets;
183        let out = std::env::var("SLIDER_PNG").unwrap_or_else(|_| "slider_showcase.png".to_string());
184        let panel = |dark: bool| {
185            let col = Column::new().spacing(22.0).padding(EdgeInsets::all(26.0))
186                .child(Slider::new(0.2).width(220.0))
187                .child(Slider::new(0.5).width(220.0))
188                .child(Slider::new(0.85).width(220.0))
189                .child(Slider::new(0.6).width(220.0).disabled());
190            let app = WidgetApp::new(280, 200);
191            if dark { app.dark() } else { app.light() }.render_png(&col)
192        };
193        std::fs::write(&out, panel(true)).unwrap();
194        std::fs::write(out.replace(".png", "_light.png"), panel(false)).unwrap();
195        println!("wrote {out}");
196    }
197
198    fn thumb_x(value: f32) -> f32 {
199        let font = FontCache::embedded();
200        let mut rec = PictureRecorder::new();
201        let tree = Rc::new(RefCell::new(RenderTree::new()));
202        let mut ctx = PaintCtx::root(
203            &mut rec,
204            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 200.0, height: 24.0 } },
205            &font, rosace_theme::built_in::dark_theme(), tree,
206        );
207        Slider::new(value).paint(&mut ctx);
208        // Last FillCircle is the thumb.
209        rec.finish().commands.into_iter().rev().find_map(|c| match c {
210            DrawCommand::FillCircle { center, .. } => Some(center.x),
211            _ => None,
212        }).expect("a thumb circle")
213    }
214
215    #[test]
216    fn thumb_tracks_value_left_to_right() {
217        assert!(thumb_x(0.0) < thumb_x(0.5), "thumb moves right as value grows");
218        assert!(thumb_x(0.5) < thumb_x(1.0), "thumb keeps moving right");
219    }
220
221    #[test]
222    fn thumb_stays_within_the_track() {
223        assert!(thumb_x(0.0) >= 0.0 && thumb_x(1.0) <= 200.0, "thumb never overflows the track");
224    }
225}