Skip to main content

rosace_widgets/tree/
chip.rs

1use std::sync::Arc;
2
3use rosace_core::types::Size;
4use rosace_render::Color;
5use super::{Widget, LayoutCtx, PaintCtx};
6use super::container::draw_rounded_rect_pub;
7
8/// A filter/tag chip that toggles — brought to the Quality Bar.
9///
10/// - **States** — unselected/selected · hover · pressed · focus-visible ·
11///   disabled.
12/// - **Motion** — the fill + text recolor ease on select; a state-layer wash
13///   fades on hover/press/focus (two channels).
14/// - **Theming** — unselected = outlined `surface_variant`, selected = `primary`
15///   fill with high-contrast text; tokens, overridable.
16/// - **A11y** — checkbox-style role (toggles) + label + value.
17/// - **Interactive-by-identity** — always owns its hit region.
18pub struct Chip {
19    label: String,
20    selected: bool,
21    disabled: bool,
22    /// `None` = read from the active theme's `typography.label_large`
23    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
24    /// for the reasoning).
25    font_size: Option<f32>,
26    height: f32,
27    color: Option<Color>,          // selected fill override
28    /// `None` = fully rounded pill (`height / 2`, the default look).
29    radius: Option<f32>,
30    on_toggle: Option<Arc<dyn Fn(bool) + Send + Sync>>,
31}
32
33impl Chip {
34    pub fn new(label: impl Into<String>) -> Self {
35        Self { label: label.into(), selected: false, disabled: false, font_size: None, height: 30.0, color: None, radius: None, on_toggle: None }
36    }
37
38    fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
39        self.font_size.unwrap_or(theme.typography.label_large.size)
40    }
41    pub fn selected(mut self) -> Self { self.selected = true; self }
42    pub fn selected_if(mut self, c: bool) -> Self { self.selected = c; self }
43    pub fn disabled(mut self) -> Self { self.disabled = true; self }
44    /// Override the selected fill (default: theme `primary`).
45    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
46    /// Corner radius — defaults to a fully rounded pill (`height / 2`).
47    /// `0.0` gives square corners; anything in between is a rounded rect.
48    pub fn radius(mut self, r: f32) -> Self { self.radius = Some(r.max(0.0)); self }
49    /// Called with the NEW selected value when tapped.
50    pub fn on_toggle(mut self, f: impl Fn(bool) + Send + Sync + 'static) -> Self {
51        self.on_toggle = Some(Arc::new(f)); self
52    }
53}
54
55fn with_alpha(c: Color, a: f32) -> Color {
56    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
57}
58
59impl Widget for Chip {
60    fn layout(&self, ctx: &LayoutCtx) -> Size {
61        let w = ctx.font.measure_text(&self.label, self.resolved_font_size(ctx.theme)) + 28.0;
62        Size { width: w, height: self.height }
63    }
64
65    fn paint(&self, ctx: &mut PaintCtx) {
66        ctx.semantics(super::Semantics::new(rosace_core::Role::Checkbox)
67            .label(&self.label)
68            .value(if self.selected { "selected" } else { "not selected" }));
69
70        match (&self.on_toggle, self.disabled) {
71            (Some(f), false) => { let f = f.clone(); let next = !self.selected; ctx.on_press(move || f(next)); }
72            _ => ctx.on_press(|| {}),
73        }
74        let focused = !self.disabled && ctx.focus_node().is_focused();
75        let hovered = !self.disabled && ctx.hovered();
76        let pressed = !self.disabled && ctx.pressed();
77
78        let t = ctx.animate_channel(0, if self.selected { 1.0 } else { 0.0 }, 0.0);
79        let wash_t = if pressed { 0.14 } else if focused { 0.10 } else if hovered { 0.07 } else { 0.0 };
80        let wash = ctx.animate_channel(1, wash_t, 0.0);
81
82        let colors = ctx.theme.colors.clone();
83        let sel_fill = self.color.unwrap_or_else(|| ctx.tc(colors.primary));
84        let unsel_fill = ctx.tc(colors.surface_variant);
85        let outline = ctx.tc(colors.outline);
86        let unsel_text = ctx.tc(colors.on_surface);
87        let sel_text = Color::rgb(252, 252, 255);
88        let dim = if self.disabled { 0.4 } else { 1.0 };
89
90        let r = ctx.rect;
91        let radius = self.radius.unwrap_or(r.size.height / 2.0);
92
93        // Fill eases unselected→selected. Wash lightens it on hover/press.
94        let mut fill = super::lerp_color(unsel_fill, sel_fill, t);
95        if wash > 0.001 { fill = super::lerp_color(fill, Color::rgb(255, 255, 255), wash); }
96        draw_rounded_rect_pub(ctx, r, with_alpha(fill, dim), radius);
97
98        // Outline fades out as it fills in.
99        if t < 0.99 {
100            ctx.stroke_rrect(r, radius, with_alpha(outline, (1.0 - t) * dim), 1.0);
101        }
102        // Focus ring.
103        if focused {
104            ctx.stroke_rrect(r, radius, with_alpha(sel_fill, 0.9), 2.0);
105        }
106
107        let fg = super::lerp_color(unsel_text, sel_text, t);
108        let font_size = self.resolved_font_size(&ctx.theme);
109        let text_w = ctx.font.measure_text(&self.label, font_size);
110        let tx = ((r.size.width - text_w) / 2.0).max(0.0);
111        let line_h = ctx.font.line_height(font_size);
112        let ty = ((r.size.height - line_h) / 2.0).max(0.0);
113        ctx.text(&self.label, tx, ty, with_alpha(fg, dim), font_size);
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use rosace_core::types::{Point, Rect};
121    use rosace_render::{FontCache, PictureRecorder};
122    use rosace_render::draw_command::DrawCommand;
123    use std::cell::RefCell;
124    use std::rc::Rc;
125    use crate::tree::RenderTree;
126
127    fn paint(selected: bool) -> Vec<DrawCommand> {
128        let font = FontCache::embedded();
129        let mut rec = PictureRecorder::new();
130        let tree = Rc::new(RefCell::new(RenderTree::new()));
131        let mut ctx = PaintCtx::root(
132            &mut rec,
133            Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 80.0, height: 30.0 } },
134            &font, rosace_theme::built_in::dark_theme(), tree,
135        );
136        let mut c = Chip::new("Filter");
137        if selected { c = c.selected(); }
138        c.paint(&mut ctx);
139        rec.finish().commands
140    }
141
142    #[test]
143    fn draws_a_pill_and_its_label() {
144        let cmds = paint(false);
145        assert!(cmds.iter().any(|c| matches!(c, DrawCommand::FillRRect { .. })), "chip is a rounded pill");
146        assert!(cmds.iter().any(|c| matches!(c, DrawCommand::DrawText { text, .. } if text == "Filter")), "shows its label");
147    }
148
149    #[test]
150    fn selected_chip_has_no_outline_but_unselected_does() {
151        assert!(paint(false).iter().any(|c| matches!(c, DrawCommand::StrokeRRect { .. })), "unselected chip is outlined");
152    }
153}