Skip to main content

rosace_widgets/tree/
checkbox.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 checkbox with optional label — brought to the Quality Bar (see
9/// `.steering/WIDGET_QUALITY_BAR.md`; matches the `Switch` exemplar).
10///
11/// - **States** — unchecked/checked/indeterminate · hover · pressed (box
12///   dips) · focus-visible (ring) · disabled (dimmed, inert).
13/// - **Motion** — box fill + border ease on toggle; the checkmark *pops in*
14///   (scales 0.5→1 while it fades); the hover/press/focus state-layer halo
15///   fades smoothly. Three independent animation channels, all idle when
16///   settled.
17/// - **Theming** — fill from `primary`, border from `outline`, ink is a
18///   bright high-contrast tick; light+dark adaptive; overridable.
19/// - **A11y** — `Role::Checkbox` with checked/unchecked value + optional label.
20/// - **Interactive-by-identity** — always owns its hit region.
21pub struct Checkbox {
22    pub checked: bool,
23    pub indeterminate: bool,
24    disabled: bool,
25    label: Option<String>,
26    box_size: f32,
27    /// `None` = read from the active theme's `typography.body_medium`
28    /// (D127 "environment" track: one canonical size source instead of a
29    /// hardcoded literal, so the accessibility text-scale multiplier — and
30    /// any future theme-wide size tuning — apply here for free). `Some` is
31    /// an explicit per-instance override, e.g. from `.size()`.
32    font_size: Option<f32>,
33    on_change: Option<Arc<dyn Fn(bool) + Send + Sync>>,
34    color: Option<Color>,
35}
36
37impl Checkbox {
38    pub fn new(checked: bool) -> Self {
39        Self {
40            checked,
41            indeterminate: false,
42            disabled: false,
43            label: None,
44            box_size: 18.0,
45            font_size: None,
46            on_change: None,
47            color: None,
48        }
49    }
50
51    pub fn label(mut self, l: impl Into<String>) -> Self { self.label = Some(l.into()); self }
52
53    /// Called with the NEW value when the control is toggled (D094).
54    pub fn on_change(mut self, f: impl Fn(bool) + Send + Sync + 'static) -> Self {
55        self.on_change = Some(Arc::new(f));
56        self
57    }
58
59    pub fn indeterminate(mut self) -> Self { self.indeterminate = true; self }
60    pub fn disabled(mut self) -> Self { self.disabled = true; self }
61    pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
62
63    /// Override the checked fill color (default: theme `primary`).
64    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
65
66    pub fn size(mut self, s: f32) -> Self { self.box_size = s; self.font_size = Some(s * 0.72); self }
67
68    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
69        self.font_size.unwrap_or(theme.typography.body_medium.size)
70    }
71}
72
73fn with_alpha(c: Color, a: f32) -> Color {
74    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
75}
76
77impl Widget for Checkbox {
78    fn layout(&self, ctx: &LayoutCtx) -> Size {
79        let font_size = self.resolved_font_size(ctx.theme);
80        let label_w = self.label.as_ref()
81            .map(|l| l.len() as f32 * font_size * 0.6 + 10.0)
82            .unwrap_or(0.0);
83        // Height clears the state-layer halo so neighbours aren't clipped.
84        Size { width: self.box_size + label_w, height: self.box_size.max(font_size * 1.4) }
85    }
86
87    fn paint(&self, ctx: &mut PaintCtx) {
88        // ── A11y ──────────────────────────────────────────────────────────
89        let mut sem = super::Semantics::new(rosace_core::Role::Checkbox)
90            .value(if self.indeterminate { "mixed" } else if self.checked { "checked" } else { "unchecked" });
91        if let Some(l) = &self.label { sem = sem.label(l); }
92        ctx.semantics(sem);
93        let font_size = self.resolved_font_size(&ctx.theme);
94
95        // ── Interactivity (identity) + focus ─────────────────────────────────
96        match (&self.on_change, self.disabled) {
97            (Some(f), false) => { let f = f.clone(); let next = !self.checked; ctx.on_press(move || f(next)); }
98            _ => ctx.on_press(|| {}),
99        }
100        let focused = !self.disabled && ctx.focus_node().is_focused();
101        let hovered = !self.disabled && ctx.hovered();
102        let pressed = !self.disabled && ctx.pressed();
103        let on = self.checked || self.indeterminate;
104
105        // ── Animation channels ───────────────────────────────────────────────
106        let t = ctx.animate_channel(0, if on { 1.0 } else { 0.0 }, 0.0);           // check progress
107        let halo_t = if pressed { 0.16 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
108        let halo = ctx.animate_channel(1, halo_t, 0.0);                              // state layer
109        let press = ctx.animate_channel(2, if pressed { 1.0 } else { 0.0 }, 0.0);    // press dip
110
111        // ── Colors (tokens) ──────────────────────────────────────────────────
112        let colors = ctx.theme.colors.clone();
113        let fill = self.color.unwrap_or_else(|| ctx.tc(colors.primary));
114        let empty = ctx.tc(colors.surface);
115        let border = ctx.tc(colors.outline);
116        let label_color = ctx.tc(colors.on_surface);
117        let ink = Color::rgb(252, 252, 255); // bright high-contrast tick
118        let dim = if self.disabled { 0.38 } else { 1.0 };
119
120        let bs = self.box_size;
121        let cx = ctx.rect.origin.x + bs / 2.0;
122        let cy = ctx.rect.origin.y + ctx.rect.size.height / 2.0;
123        let radius = (bs * 0.22).max(3.0);
124
125        // ── State-layer halo (behind the box) ────────────────────────────────
126        if halo > 0.001 {
127            let hc = super::lerp_color(border, fill, t);
128            ctx.fill_circle(Point { x: cx, y: cy }, bs * 0.5 + 8.0, with_alpha(hc, halo));
129        }
130
131        // ── The box (dips slightly on press) ─────────────────────────────────
132        let scale = 1.0 - press * 0.08;
133        let half = bs * 0.5 * scale;
134        let box_rect = Rect {
135            origin: Point { x: cx - half, y: cy - half },
136            size: Size { width: half * 2.0, height: half * 2.0 },
137        };
138        // Empty fill fades to the checked fill as t rises.
139        draw_rounded_rect_pub(ctx, box_rect, with_alpha(super::lerp_color(empty, fill, t), dim), radius);
140        if t < 0.99 {
141            ctx.stroke_rrect(box_rect, radius, with_alpha(super::lerp_color(border, fill, t), (1.0 - t) * dim + t * dim), 1.5);
142        }
143
144        // ── Mark: indeterminate dash, or a checkmark that pops in ─────────────
145        if t > 0.01 {
146            if self.indeterminate {
147                let w = bs * 0.5 * t;
148                ctx.fill_rect(Rect {
149                    origin: Point { x: cx - w / 2.0, y: cy - bs * 0.06 },
150                    size: Size { width: w, height: (bs * 0.12).max(2.0) },
151                }, with_alpha(ink, dim));
152            } else {
153                let px = bs * 0.9 * (0.6 + 0.4 * t); // scale-in pop
154                // The bundled Material Symbols icon face's check glyph
155                // (`IconKind::Check`'s codepoint), not the raw Unicode
156                // U+2713 CHECK MARK — that renders as a tofu box on Android
157                // (found live: no OS-level font fallback there, and the body
158                // face doesn't carry it), same class of bug already fixed
159                // for Dropdown's/Accordion's chevrons.
160                let glyph = "\u{e668}";
161                let tw = ctx.font.measure_text(glyph, px);
162                let lh = ctx.font.line_height(px);
163                ctx.draw_text_at(
164                    glyph,
165                    Point { x: cx - tw / 2.0, y: cy - lh / 2.0 },
166                    with_alpha(ink, t * dim),
167                    px,
168                );
169            }
170        }
171
172        // ── Focus ring ────────────────────────────────────────────────────────
173        if focused {
174            let ring = Rect {
175                origin: Point { x: cx - bs * 0.5 - 3.0, y: cy - bs * 0.5 - 3.0 },
176                size: Size { width: bs + 6.0, height: bs + 6.0 },
177            };
178            ctx.stroke_rrect(ring, radius + 3.0, with_alpha(fill, 0.9), 2.0);
179        }
180
181        // ── Label ──────────────────────────────────────────────────────────────
182        if let Some(label) = &self.label {
183            let line_h = ctx.font.line_height(font_size);
184            let ty = ((ctx.rect.size.height - line_h) / 2.0).max(0.0);
185            ctx.text(label, bs + 10.0, ty, with_alpha(label_color, dim), font_size);
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use rosace_render::{FontCache, PictureRecorder};
194    use rosace_render::draw_command::DrawCommand;
195    use std::cell::RefCell;
196    use std::rc::Rc;
197    use crate::tree::RenderTree;
198
199    fn paint(checked: bool, indeterminate: bool) -> Vec<DrawCommand> {
200        let font = FontCache::embedded();
201        let mut rec = PictureRecorder::new();
202        let tree = Rc::new(RefCell::new(RenderTree::new()));
203        let mut ctx = PaintCtx::root(
204            &mut rec,
205            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 18.0, height: 18.0 } },
206            &font, rosace_theme::built_in::dark_theme(), tree,
207        );
208        let mut c = Checkbox::new(checked);
209        if indeterminate { c = c.indeterminate(); }
210        c.paint(&mut ctx);
211        rec.finish().commands
212    }
213
214    #[test]
215    #[ignore] // visual: CHECKBOX_PNG=/path cargo test -p rosace-widgets checkbox_showcase -- --ignored --nocapture
216    fn checkbox_showcase() {
217        use super::super::app::WidgetApp;
218        use super::super::Column;
219        use crate::EdgeInsets;
220        let out = std::env::var("CHECKBOX_PNG").unwrap_or_else(|_| "checkbox_showcase.png".to_string());
221        let panel = |dark: bool| {
222            let col = Column::new().spacing(16.0).padding(EdgeInsets::all(24.0))
223                .child(Checkbox::new(false).label("Unchecked"))
224                .child(Checkbox::new(true).label("Checked"))
225                .child(Checkbox::new(false).indeterminate().label("Indeterminate"))
226                .child(Checkbox::new(true).disabled().label("Disabled"));
227            let app = WidgetApp::new(220, 200);
228            if dark { app.dark() } else { app.light() }.render_png(&col)
229        };
230        std::fs::write(&out, panel(true)).unwrap();
231        std::fs::write(out.replace(".png", "_light.png"), panel(false)).unwrap();
232        println!("wrote {out}");
233    }
234
235    #[test]
236    fn checked_draws_a_tick_glyph() {
237        // The icon face's check codepoint, not raw Unicode U+2713 — see the
238        // paint()-site comment for why (tofu on Android with no icon face).
239        assert!(paint(true, false).iter().any(|c| matches!(c, DrawCommand::DrawText { text, .. } if text == "\u{e668}")),
240            "checked box must draw the icon-face check glyph");
241    }
242
243    #[test]
244    fn indeterminate_draws_a_dash_not_a_tick() {
245        let cmds = paint(false, true);
246        assert!(!cmds.iter().any(|c| matches!(c, DrawCommand::DrawText { text, .. } if text == "\u{2713}")),
247            "indeterminate must not draw a tick");
248    }
249
250    #[test]
251    fn unchecked_box_still_paints_its_outline() {
252        assert!(paint(false, false).iter().any(|c| matches!(c, DrawCommand::FillRRect { .. })),
253            "the box itself is a rounded rect in every state");
254    }
255}