Skip to main content

rosace_widgets/tree/
skeleton.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_render::{Color, DrawCommand};
3use super::{Widget, LayoutCtx, PaintCtx};
4
5/// A shimmering loading placeholder — a rounded block with a soft highlight
6/// band that sweeps left→right across it. Self-animating.
7pub struct Skeleton {
8    width: Option<f32>,
9    height: f32,
10    radius: f32,
11    /// Base hue the shimmer is drawn in (white by default) — the actual
12    /// painted colors are this RGB at varying alpha (see `paint`), so
13    /// overriding it doesn't need separate base/highlight builders.
14    color: Color,
15    /// Sweep top-to-bottom instead of left-to-right.
16    vertical: bool,
17    /// Set by [`Self::circle`] — the shimmer band would bleed past the
18    /// circle's curve (the renderer has no rounded-clip primitive), so
19    /// circles get a breathing-alpha pulse instead (see `paint`).
20    is_circle: bool,
21}
22
23impl Skeleton {
24    pub fn new() -> Self { Self { width: None, height: 16.0, radius: 6.0, color: Color::rgb(255, 255, 255), vertical: false, is_circle: false } }
25    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
26    pub fn height(mut self, h: f32) -> Self { self.height = h; self }
27    pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
28    /// Shimmer hue (white by default).
29    pub fn color(mut self, c: Color) -> Self { self.color = c; self }
30    /// Sweep the shimmer top-to-bottom instead of the default left-to-right.
31    pub fn vertical(mut self, v: bool) -> Self { self.vertical = v; self }
32    /// A circular avatar-sized skeleton.
33    pub fn circle(size: f32) -> Self { Self { width: Some(size), height: size, radius: size / 2.0, color: Color::rgb(255, 255, 255), vertical: false, is_circle: true } }
34}
35
36impl Default for Skeleton { fn default() -> Self { Self::new() } }
37
38impl Widget for Skeleton {
39    fn layout(&self, ctx: &LayoutCtx) -> Size {
40        let w = self.width.unwrap_or_else(|| super::avail_w(ctx.constraints));
41        ctx.constraints.constrain(Size { width: w, height: self.height })
42    }
43
44    fn paint(&self, ctx: &mut PaintCtx) {
45        let r = ctx.rect;
46        let (cr, cg, cb) = (self.color.r, self.color.g, self.color.b);
47        let base = Color::rgba(cr, cg, cb, 30);
48        let hi = Color::rgba(cr, cg, cb, 95);
49
50        // Base block.
51        ctx.fill_rrect(r, self.radius, base);
52
53        let phase = (super::anim_clock() / 1.3).fract(); // 0..1, continuous
54
55        if self.is_circle {
56            // PushClip is an axis-aligned rect — clipping a sweeping band to
57            // it leaves the highlight painting square corners outside the
58            // circle's actual curve (looked like a barcode scanner). Circles
59            // get a breathing-alpha pulse instead: same painted shape as the
60            // base (fill_rrect with radius = size/2), so it can't leak.
61            let pulse = 1.0 - (phase * 2.0 - 1.0).abs(); // 0 -> 1 -> 0 triangle wave
62            let alpha = (hi.a as f32 * pulse) as u8;
63            ctx.fill_rrect(r, self.radius, Color::rgba(cr, cg, cb, alpha));
64        } else {
65            let clear = Color::rgba(cr, cg, cb, 0);
66            // A soft highlight band that sweeps across, clipped to the shape.
67            ctx.record(DrawCommand::PushClip { rect: r });
68            if self.vertical {
69                let bh = (r.size.height * 0.35).max(24.0);
70                let y = r.origin.y - bh + (r.size.height + bh) * phase; // enters top, exits bottom
71                let half = bh / 2.0;
72                ctx.fill_gradient(
73                    Rect { origin: Point { x: r.origin.x, y }, size: Size { width: r.size.width, height: half } },
74                    0.0, clear, hi, true);
75                ctx.fill_gradient(
76                    Rect { origin: Point { x: r.origin.x, y: y + half }, size: Size { width: r.size.width, height: half } },
77                    0.0, hi, clear, true);
78            } else {
79                let bw = (r.size.width * 0.35).max(24.0);
80                let x = r.origin.x - bw + (r.size.width + bw) * phase; // enters left, exits right
81                let half = bw / 2.0;
82                // Symmetric band: transparent → highlight → transparent (two ramps).
83                ctx.fill_gradient(
84                    Rect { origin: Point { x, y: r.origin.y }, size: Size { width: half, height: r.size.height } },
85                    0.0, clear, hi, false);
86                ctx.fill_gradient(
87                    Rect { origin: Point { x: x + half, y: r.origin.y }, size: Size { width: half, height: r.size.height } },
88                    0.0, hi, clear, false);
89            }
90            ctx.record(DrawCommand::PopClip);
91        }
92        ctx.request_animation();
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use rosace_layout::Constraints;
100
101    #[test]
102    fn color_builder_does_not_change_layout_size() {
103        let font = rosace_render::FontCache::embedded();
104        let theme = rosace_theme::built_in::dark_theme();
105        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
106        let base = Skeleton::new().width(100.0).height(20.0);
107        let customized = Skeleton::new().width(100.0).height(20.0).color(Color::rgb(200, 50, 50));
108        assert_eq!(base.layout(&ctx), customized.layout(&ctx));
109    }
110}