Skip to main content

rosace_widgets/tree/
switch.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_render::Color;
5use super::{Widget, LayoutCtx, PaintCtx};
6use super::container::draw_rounded_rect_pub;
7
8/// A toggle switch — the reference "premium widget" (Q0 quality-bar exemplar).
9///
10/// Ships beautiful with zero config and stays fully overridable. It layers
11/// every state a real toggle needs on top of one animated value:
12///
13/// - **Motion** — the thumb *slides* (theme-eased) between off/on; because the
14///   thumb radius is derived from that same 0→1 position, it also *grows* as it
15///   settles on, Material-3 style, for free (one node scalar, see `animate_to`).
16/// - **States** — idle · hover · pressed (thumb stretches) · focus-visible
17///   (ring + state-layer halo) · disabled (dimmed, inert) · on/off.
18/// - **Elevation** — the thumb casts a soft drop shadow so it reads as a
19///   physical knob above the track.
20/// - **Theming** — track/thumb/halo all come from theme tokens (`primary`,
21///   `surface_variant`, `on_primary`, `outline`, `shadow`); light+dark adapt
22///   automatically. Any of them can be overridden per-instance.
23/// - **A11y** — `Role::Switch` with an on/off value, a Tab-focusable node, and
24///   an optional label for screen readers.
25/// - **Interactive-by-identity** — always owns its hit region, wired or not, so
26///   a tap can never fall through to a pannable surface behind it.
27pub struct Switch {
28    /// The current value.
29    pub on: bool,
30    disabled: bool,
31    label: Option<String>,
32    width: f32,
33    height: f32,
34    on_change: Option<Arc<dyn Fn(bool) + Send + Sync>>,
35    on_color: Option<Color>,
36    off_color: Option<Color>,
37    thumb_color: Option<Color>,
38}
39
40impl Switch {
41    pub fn new(on: bool) -> Self {
42        Self {
43            on,
44            disabled: false,
45            label: None,
46            width: 44.0,
47            height: 24.0,
48            on_change: None,
49            on_color: None,
50            off_color: None,
51            thumb_color: None,
52        }
53    }
54
55    /// Called with the NEW value when the switch is toggled (D094).
56    pub fn on_change(mut self, f: impl Fn(bool) + Send + Sync + 'static) -> Self {
57        self.on_change = Some(Arc::new(f));
58        self
59    }
60
61    /// Non-interactive, dimmed, and inert (still absorbs the tap so nothing
62    /// behind it reacts).
63    pub fn disabled(mut self) -> Self { self.disabled = true; self }
64    pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
65
66    /// Accessibility label announced by screen readers alongside the on/off value.
67    pub fn label(mut self, l: impl Into<String>) -> Self { self.label = Some(l.into()); self }
68
69    /// Override the track size (default 44×24). Proportions stay tasteful at
70    /// any reasonable size.
71    pub fn size(mut self, width: f32, height: f32) -> Self {
72        self.width = width;
73        self.height = height;
74        self
75    }
76
77    /// Override the on-track color (default: theme `primary`).
78    pub fn on_color(mut self, c: Color) -> Self { self.on_color = Some(c); self }
79    /// Override the off-track color (default: theme `surface_variant`).
80    pub fn off_color(mut self, c: Color) -> Self { self.off_color = Some(c); self }
81    /// Override the thumb color (default: theme `on_primary` on / `outline` off).
82    pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
83}
84
85fn with_alpha(c: Color, a: f32) -> Color {
86    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
87}
88
89impl Widget for Switch {
90    fn layout(&self, ctx: &LayoutCtx) -> Size {
91        ctx.constraints.constrain(Size { width: self.width, height: self.height })
92    }
93
94    fn paint(&self, ctx: &mut PaintCtx) {
95        // ── A11y ────────────────────────────────────────────────────────────
96        let mut sem = super::Semantics::new(rosace_core::Role::Switch)
97            .value(if self.on { "on" } else { "off" });
98        if let Some(l) = &self.label { sem = sem.label(l); }
99        ctx.semantics(sem);
100
101        // ── Interactivity (identity) + keyboard focus ─────────────────────────
102        // Always own the hit region; a disabled switch absorbs but does nothing.
103        let toggle: Arc<dyn Fn() + Send + Sync> = match (&self.on_change, self.disabled) {
104            (Some(f), false) => { let f = f.clone(); let next = !self.on; Arc::new(move || f(next)) }
105            _ => Arc::new(|| {}),
106        };
107        ctx.register_hit(toggle);
108        let focused = !self.disabled && ctx.focus_node().is_focused();
109
110        // ── Three independent animated channels (smooth, not snapping) ───────
111        let hovered = !self.disabled && ctx.hovered();
112        let pressed = !self.disabled && ctx.pressed();
113        // ch0: position (off→on). ch1: state-layer halo opacity. ch2: press amount.
114        let t = ctx.animate_channel(0, if self.on { 1.0 } else { 0.0 }, 0.0);
115        let halo_target = if pressed { 0.16 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
116        let halo = ctx.animate_channel(1, halo_target, 0.0);
117        let press_amt = ctx.animate_channel(2, if pressed { 1.0 } else if hovered { 0.35 } else { 0.0 }, 0.0);
118
119        let colors = &ctx.theme.colors;
120        let on_track  = self.on_color.unwrap_or_else(|| ctx.tc(colors.primary));
121        let off_track = self.off_color.unwrap_or_else(|| ctx.tc(colors.surface_variant));
122        // The knob is a bright, high-contrast disc in BOTH states (the iOS
123        // model) — it reads as a physical thumb over any track colour, in
124        // light or dark, and its drop shadow keeps it defined even on a
125        // low-contrast off-track. Overridable via `.thumb_color(..)`.
126        let thumb_c   = self.thumb_color.unwrap_or_else(|| Color::rgb(250, 250, 252));
127        let outline   = ctx.tc(colors.outline);
128        let shadow    = ctx.tc(colors.shadow);
129
130        let dim = if self.disabled { 0.38 } else { 1.0 };
131        let r = ctx.rect;
132        let radius = r.size.height / 2.0;
133
134        // ── Track (color eased between off/on; off-state gets an outline that
135        //     fades out as it turns on) ────────────────────────────────────────
136        let track = super::lerp_color(off_track, on_track, t);
137        draw_rounded_rect_pub(ctx, r, with_alpha(track, dim), radius);
138        if t < 1.0 {
139            ctx.stroke_rrect(r, radius, with_alpha(outline, (1.0 - t) * dim), 1.0);
140        }
141
142        // ── Thumb geometry (position + radius both come from `t`) ────────────
143        let pad = 2.0;
144        let base_r = (r.size.height / 2.0) - pad;      // fills the track minus padding
145        let off_r = base_r - 2.0;                       // smaller when off (M3)
146        let on_r = base_r;                              // full when on
147        let thumb_r = off_r + (on_r - off_r) * t + press_amt * 1.5; // smooth press stretch
148
149        let cy = r.origin.y + r.size.height / 2.0;
150        let off_cx = r.origin.x + pad + base_r;
151        let on_cx = r.origin.x + r.size.width - pad - base_r;
152        let cx = off_cx + (on_cx - off_cx) * t;
153
154        // ── State layer: a translucent halo behind the thumb on hover/press/
155        //     focus — the Material-3 signal that a control is live ─────────────
156        if halo > 0.001 {
157            let halo_color = super::lerp_color(outline, on_track, t);
158            ctx.fill_circle(Point { x: cx, y: cy }, thumb_r + 8.0, with_alpha(halo_color, halo));
159        }
160
161        // ── Thumb elevation (soft drop shadow) then the thumb itself ─────────
162        let d = thumb_r * 2.0;
163        ctx.fill_shadow_rrect(
164            Rect { origin: Point { x: cx - thumb_r, y: cy - thumb_r + 1.0 }, size: Size { width: d, height: d } },
165            thumb_r,
166            with_alpha(shadow, 0.28 * dim),
167            4.0,
168        );
169        ctx.fill_circle(Point { x: cx, y: cy }, thumb_r, with_alpha(thumb_c, dim));
170        // No always-on repaint: animate_channel self-drives frames only while a
171        // channel is still easing, then goes idle (D111 — never animate a
172        // settled, idle widget).
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use rosace_render::{FontCache, PictureRecorder};
180    use rosace_render::draw_command::DrawCommand;
181    use std::cell::RefCell;
182    use std::rc::Rc;
183    use crate::tree::RenderTree;
184
185    fn paint_switch(on: bool, disabled: bool) -> Vec<DrawCommand> {
186        let font = FontCache::embedded();
187        let mut rec = PictureRecorder::new();
188        let tree = Rc::new(RefCell::new(RenderTree::new()));
189        let mut ctx = PaintCtx::root(
190            &mut rec,
191            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 44.0, height: 24.0 } },
192            &font,
193            rosace_theme::built_in::dark_theme(),
194            tree,
195        );
196        let mut s = Switch::new(on);
197        if disabled { s = s.disabled(); }
198        s.paint(&mut ctx);
199        rec.finish().commands
200    }
201
202    #[test]
203    fn default_size_is_the_premium_track() {
204        let font = FontCache::embedded();
205        let theme = rosace_theme::built_in::dark_theme();
206        let ctx = LayoutCtx::new(rosace_layout::Constraints::loose(200.0, 200.0), &font, &theme);
207        assert_eq!(Switch::new(false).layout(&ctx), Size { width: 44.0, height: 24.0 });
208    }
209
210    #[test]
211    fn paints_track_thumb_shadow_and_thumb() {
212        let cmds = paint_switch(true, false);
213        assert!(cmds.iter().any(|c| matches!(c, DrawCommand::DrawShadow { .. })),
214            "thumb must cast an elevation shadow");
215        let circles = cmds.iter().filter(|c| matches!(c, DrawCommand::FillCircle { .. })).count();
216        assert!(circles >= 1, "thumb is a filled circle");
217    }
218
219    #[test]
220    #[ignore] // visual showcase — run explicitly: cargo test -p rosace-widgets switch_showcase -- --ignored --nocapture
221    fn switch_showcase() {
222        use super::super::app::WidgetApp;
223        use super::super::Column;
224        use crate::EdgeInsets;
225        let out = std::env::var("SWITCH_PNG").unwrap_or_else(|_| "switch_showcase.png".to_string());
226        let panel = |dark: bool| {
227            let col = Column::new().spacing(22.0).padding(EdgeInsets::all(28.0))
228                .child(Switch::new(false))
229                .child(Switch::new(true))
230                .child(Switch::new(false).disabled())
231                .child(Switch::new(true).disabled());
232            let app = WidgetApp::new(120, 220);
233            if dark { app.dark() } else { app.light() }.render_png(&col)
234        };
235        std::fs::write(&out, panel(true)).unwrap();
236        let light = out.replace(".png", "_light.png");
237        std::fs::write(&light, panel(false)).unwrap();
238        println!("wrote {out} and {light}");
239    }
240
241    #[test]
242    fn on_and_off_thumbs_land_at_different_x() {
243        // The thumb travels: its circle center x must differ between states.
244        let cx = |on: bool| {
245            paint_switch(on, false).into_iter().find_map(|c| match c {
246                DrawCommand::FillCircle { center, .. } => Some(center.x),
247                _ => None,
248            }).expect("a thumb circle")
249        };
250        assert!(cx(true) > cx(false), "on-thumb must sit right of off-thumb");
251    }
252}