qframe/widgets/
skeleton.rs1use 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
9const BAND: f32 = 10.0;
11
12const LINE_WIDTHS: [u16; 4] = [100, 86, 94, 72];
14
15const LAST_LINE: u16 = 58;
17
18const BLOCK_ROWS: u16 = 3;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum Shape {
23 Lines(u16),
24 Avatar,
25 Block,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Skeleton {
39 shape: Shape,
40}
41
42impl Skeleton {
43 #[must_use]
45 pub fn lines(count: u16) -> Self {
46 Self { shape: Shape::Lines(count.max(1)) }
47 }
48
49 #[must_use]
51 pub fn avatar() -> Self {
52 Self { shape: Shape::Avatar }
53 }
54
55 #[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
113struct 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 let travel = f32::from(cx.buf.area.width) + BAND * 2.0;
126 Self { center: Some(Easing::EaseInOut.apply(t) * travel - BAND) }
127 }
128
129 fn intensity(&self, x: i32) -> f32 {
131 let Some(center) = self.center else {
132 return 0.0;
133 };
134 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}