Skip to main content

qframe/widgets/
skeleton.rs

1//! Skeletons: the shape of content that is still loading.
2
3use crate::geometry::{Rect, Size, clamp_u16};
4use crate::icons::GlyphMode;
5use crate::motion::Easing;
6use crate::style::CellStyle;
7use crate::widget::{MeasureCx, PaintCx, Widget};
8
9/// Width of the band of light, in cells.
10const BAND: f32 = 10.0;
11
12/// Widths of successive text lines, in percent of the width; the last line is always short.
13const LINE_WIDTHS: [u16; 4] = [100, 86, 94, 72];
14
15/// Width of the last text line, in percent.
16const LAST_LINE: u16 = 58;
17
18/// Height of a block that is not given one, in rows.
19const BLOCK_ROWS: u16 = 3;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum Shape {
23    Lines(u16),
24    Avatar,
25    Block,
26}
27
28/// A placeholder in the shape of content that is on its way: text lines, an avatar or a block.
29///
30/// Shapes are drawn in a quiet tone, and the signature sweep passes over them: a band of light
31/// that moves across the screen once per `motion.shimmer`, blended cell by cell. The band is
32/// placed by screen column, so skeletons side by side share one sweep. With reduced motion the
33/// shapes stand still. Text lines are drawn in the upper half of each row so lines read apart;
34/// ASCII mode fills whole cells.
35///
36/// Style keys: `skeleton` (`bg` for the shapes, `highlight` for the light).
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Skeleton {
39    shape: Shape,
40}
41
42impl Skeleton {
43    /// `count` lines of text of varied widths, the last one short, like a paragraph.
44    #[must_use]
45    pub fn lines(count: u16) -> Self {
46        Self { shape: Shape::Lines(count.max(1)) }
47    }
48
49    /// A two-cell avatar or icon.
50    #[must_use]
51    pub fn avatar() -> Self {
52        Self { shape: Shape::Avatar }
53    }
54
55    /// A block that fills the area it gets: a chart, an image, a card. Three rows unless its
56    /// node is given a height.
57    #[must_use]
58    pub fn block() -> Self {
59        Self { shape: Shape::Block }
60    }
61}
62
63impl<Msg: 'static> Widget<Msg> for Skeleton {
64    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
65        let size = match self.shape {
66            Shape::Lines(count) => Size::new(available.width, count),
67            Shape::Avatar => Size::new(2, 1),
68            Shape::Block => Size::new(available.width, BLOCK_ROWS),
69        };
70        size.min(available)
71    }
72
73    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
74        if area.is_empty() {
75            return;
76        }
77        let style = cx.style("skeleton", None, &[]);
78        let base = style.color("bg").unwrap_or_else(|| cx.color("raised"));
79        let light = style.color("highlight").unwrap_or_else(|| cx.color("active"));
80        let sweep = Sweep::new(cx);
81        let ascii = cx.env().glyph_mode() == GlyphMode::Ascii;
82        match self.shape {
83            Shape::Lines(count) => {
84                for row in 0..count.min(area.height) {
85                    let percent = if row + 1 == count && count > 1 {
86                        LAST_LINE
87                    } else {
88                        LINE_WIDTHS[usize::from(row) % LINE_WIDTHS.len()]
89                    };
90                    let width = clamp_u16(i32::from(area.width) * i32::from(percent) / 100).max(1);
91                    let y = area.y + i32::from(row);
92                    for column in 0..width {
93                        let x = area.x + i32::from(column);
94                        let color = base.mix(light, sweep.intensity(x));
95                        if ascii {
96                            cx.clear(Rect::new(x, y, 1, 1), color);
97                        } else {
98                            cx.text(x, y, "▀", CellStyle::fg(color), 1);
99                        }
100                    }
101                }
102            }
103            Shape::Avatar | Shape::Block => {
104                for column in 0..area.width {
105                    let x = area.x + i32::from(column);
106                    cx.clear(Rect::new(x, area.y, 1, area.height), base.mix(light, sweep.intensity(x)));
107                }
108            }
109        }
110    }
111}
112
113/// Where the band of light is in this frame.
114struct Sweep {
115    center: Option<f32>,
116}
117
118impl Sweep {
119    fn new(cx: &mut PaintCx<'_>) -> Self {
120        if cx.reduced_motion() {
121            return Self { center: None };
122        }
123        let t = cx.cycle(cx.env().theme().motion().shimmer);
124        // The band crosses the whole screen, so every skeleton on it is lit in turn.
125        let travel = f32::from(cx.buf.area.width) + BAND * 2.0;
126        Self { center: Some(Easing::EaseInOut.apply(t) * travel - BAND) }
127    }
128
129    /// How lit screen column `x` is, from 0 to 1.
130    fn intensity(&self, x: i32) -> f32 {
131        let Some(center) = self.center else {
132            return 0.0;
133        };
134        // Screen columns fit f32 exactly.
135        let distance = ((x as f32 + 0.5) - center).abs() / BAND;
136        if distance >= 1.0 { 0.0 } else { (1.0 - distance).powf(1.6) }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use std::time::Duration;
143
144    use super::*;
145    use crate::color::Rgb;
146    use crate::runtime::{App, Command, Harness};
147    use crate::widget::View;
148
149    fn row_colors(h: &Harness<Demo>, y: u16, width: u16) -> Vec<Option<Rgb>> {
150        (0..width).map(|x| h.fg(x, y)).collect()
151    }
152
153    struct Demo;
154
155    impl App for Demo {
156        type Msg = ();
157        fn update(&mut self, _: ()) -> Command<()> {
158            Command::none()
159        }
160        fn view(&self, ui: &mut View<'_, ()>) {
161            ui.row(|ui| {
162                ui.add(Skeleton::avatar());
163                ui.add(Skeleton::lines(3)).width(crate::widget::Length::Cells(10));
164            })
165            .gap(1);
166            ui.add(Skeleton::block()).width(crate::widget::Length::Cells(6)).height(crate::widget::Length::Cells(2));
167        }
168    }
169
170    #[test]
171    fn draws_lines_of_varied_widths_beside_an_avatar() {
172        let h = Harness::new(Demo, 16, 5);
173        assert_eq!(h.screen(), "   ▀▀▀▀▀▀▀▀▀▀\n   ▀▀▀▀▀▀▀▀\n   ▀▀▀▀▀\n\n\n");
174        let raised = h.env().theme().color("raised");
175        assert_eq!(h.bg(0, 0), raised);
176        assert_eq!(h.bg(3, 3), raised);
177    }
178
179    #[test]
180    fn light_sweeps_and_rests_under_reduced_motion() {
181        let mut h = Harness::new(Demo, 16, 5);
182        h.advance(Duration::from_millis(700));
183        let early = row_colors(&h, 0, 16);
184        h.advance(Duration::from_millis(250));
185        let later = row_colors(&h, 0, 16);
186        assert_ne!(early, later);
187        h.set_reduced_motion(true);
188        let raised = h.env().theme().color("raised");
189        assert!(row_colors(&h, 0, 13).iter().skip(3).all(|color| *color == raised));
190    }
191
192    #[test]
193    fn ascii_fills_whole_cells() {
194        let mut h = Harness::new(Demo, 16, 5);
195        h.set_glyph_mode(GlyphMode::Ascii);
196        assert_eq!(h.screen(), "\n\n\n\n\n");
197        assert_eq!(h.bg(12, 0), h.env().theme().color("raised"));
198    }
199}