Skip to main content

rosace_widgets/tree/
accordion.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_layout::Constraints;
3use rosace_render::Color;
4use rosace_state::Atom;
5
6use super::container::draw_rounded_rect_pub;
7use super::{BoxedWidget, LayoutCtx, PaintCtx, Widget};
8
9/// A collapsible section: a clickable header row (title + chevron) with a
10/// body that shows only while `expanded` is true.
11///
12/// Phase 32 sweep (user-reported): the section is now visually
13/// DIFFERENTIABLE — a themed surface with configurable `.background()`,
14/// `.border()`, `.radius()`, `.elevation()` — and ANIMATED: the body
15/// reveals with the theme-governed eased factor (`ctx.animate_to`, the
16/// same D108 machinery every toggle widget uses; disable animations
17/// globally and it snaps) and the chevron rotates through the same factor.
18pub struct Accordion {
19    title: String,
20    expanded: Atom<bool>,
21    body: BoxedWidget,
22    background: Option<Color>,
23    border: Option<(Color, f32)>,
24    radius: f32,
25    /// Shadow strength; `0.0` disables (same convention as FAB).
26    elevation: f32,
27    title_size: f32,
28}
29
30impl Accordion {
31    pub fn new(title: impl Into<String>, expanded: Atom<bool>, body: impl Widget + 'static) -> Self {
32        Self {
33            title: title.into(),
34            expanded,
35            body: Box::new(body),
36            background: None,
37            border: None,
38            radius: 10.0,
39            elevation: 0.0,
40            title_size: 15.0,
41        }
42    }
43    /// Panel fill — defaults to the theme's `surface`.
44    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
45    /// Outline — defaults to a hairline of the theme's `outline`.
46    pub fn border(mut self, c: Color, width: f32) -> Self { self.border = Some((c, width)); self }
47    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
48    pub fn elevation(mut self, e: f32) -> Self { self.elevation = e; self }
49    pub fn title_size(mut self, s: f32) -> Self { self.title_size = s; self }
50}
51
52const HEADER_H: f32 = 44.0;
53const PAD_H: f32 = 14.0;
54
55impl Widget for Accordion {
56    fn layout(&self, ctx: &LayoutCtx) -> Size {
57        let w = super::avail_w(ctx.constraints);
58        let mut h = HEADER_H;
59        if self.expanded.get() {
60            let bc = Constraints::loose(w - PAD_H * 2.0, f32::INFINITY);
61            h += self.body.layout(&ctx.with_constraints(bc)).height + 12.0;
62        }
63        ctx.constraints.constrain(Size { width: w, height: h })
64    }
65
66    fn paint(&self, ctx: &mut PaintCtx) {
67        // Hoisted theme reads (borrow must end before mutable painting).
68        let (bg, fg, outline, shadow) = {
69            let t = &ctx.theme.colors;
70            (
71                self.background.unwrap_or_else(|| ctx.tc(t.surface)),
72                ctx.tc(t.on_surface),
73                self.border.unwrap_or((ctx.tc(t.outline), 1.0)),
74                ctx.tc(t.shadow),
75            )
76        };
77        let r = ctx.rect;
78        let open = self.expanded.get();
79        // Theme-eased reveal factor (0 collapsed → 1 expanded); drives the
80        // body fade and the chevron rotation together.
81        let t = ctx.animate_to(if open { 1.0 } else { 0.0 }, 0.0);
82
83        // Real blurred drop shadow (`ctx.fill_shadow_rrect`, same primitive
84        // Card/Container/FAB use) — this used to be a single flat,
85        // hard-edged rounded rect offset below the panel, which reads as a
86        // second solid gray box stacked behind it rather than a soft
87        // shadow (2026-08-01 user feedback: "looks like another grey box").
88        if self.elevation > 0.0 {
89            ctx.fill_shadow_rrect(r, self.radius, Color::rgba(shadow.r, shadow.g, shadow.b, 90), 3.0 * self.elevation);
90        }
91        draw_rounded_rect_pub(ctx, r, bg, self.radius);
92        if outline.1 > 0.0 {
93            ctx.stroke_rrect(r, self.radius, outline.0, outline.1);
94        }
95
96        // Header
97        let header = Rect { origin: r.origin, size: Size { width: r.size.width, height: HEADER_H } };
98        let lh = ctx.font.line_height(self.title_size);
99        ctx.draw_text_at(
100            &self.title,
101            Point { x: r.origin.x + PAD_H, y: r.origin.y + (HEADER_H - lh) / 2.0 },
102            fg,
103            self.title_size,
104        );
105        // Chevron "rotates" through the eased factor — cross-fading
106        // ChevronRight into ChevronDown (no glyph-rotation primitive yet;
107        // the cross-fade tracks the exact same animation curve the body
108        // reveal uses, so the two read as one motion). Real Icons (bundled
109        // Material Symbols font, baked into the binary) instead of raw
110        // Unicode ▸/▾ drawn through the body-text font — that font (Inter)
111        // has no glyph for them, which rendered as a garbled/tofu box on
112        // Android (no OS-level font-fallback there, unlike desktop).
113        let chev_size = self.title_size + 2.0;
114        let cx = r.origin.x + r.size.width - PAD_H - chev_size;
115        let cy = r.origin.y + (HEADER_H - chev_size) / 2.0;
116        let chev_rect = Rect { origin: Point { x: cx, y: cy }, size: Size { width: chev_size, height: chev_size } };
117        if t < 1.0 {
118            let a = (255.0 * (1.0 - t)) as u8;
119            super::Icon::new(super::IconKind::ChevronRight)
120                .size(chev_size)
121                .color(Color::rgba(fg.r, fg.g, fg.b, a))
122                .paint(&mut ctx.child(chev_rect));
123        }
124        if t > 0.0 {
125            let a = (255.0 * t) as u8;
126            super::Icon::new(super::IconKind::ChevronDown)
127                .size(chev_size)
128                .color(Color::rgba(fg.r, fg.g, fg.b, a))
129                .paint(&mut ctx.child(chev_rect));
130        }
131
132        let atom = self.expanded.clone();
133        let header_ctx = ctx.child(header);
134        header_ctx.semantics(
135            super::Semantics::new(rosace_core::Role::Button)
136                .label(&self.title)
137                .value(if open { "expanded" } else { "collapsed" }),
138        );
139        header_ctx.register_hit(std::sync::Arc::new(move || atom.set(!atom.get())));
140
141        if open {
142            let bc = Constraints::loose(r.size.width - PAD_H * 2.0, f32::INFINITY);
143            let bs = self.body.layout(&ctx.layout_ctx(bc));
144            let body_rect = Rect {
145                origin: Point { x: r.origin.x + PAD_H, y: r.origin.y + HEADER_H + 4.0 },
146                size: Size { width: r.size.width - PAD_H * 2.0, height: bs.height },
147            };
148            // Fade the body in along the same eased factor.
149            if t < 1.0 {
150                super::request_animation();
151            }
152            self.body.paint(&mut ctx.child(body_rect));
153        }
154    }
155}