1use ratatui_core::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, Style},
5 widgets::Widget,
6};
7
8use crate::animation::{cell_intensity, interpolate_color, AnimationMode};
9use crate::defaults;
10
11const DOT: [[u8; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
20
21const BRAILLE_BLANK: u32 = 0x2800;
23
24const DEFAULT_AMPLITUDES: [f32; 3] = [0.7, 0.45, 0.85];
26
27const DEFAULT_FREQUENCIES: [f32; 3] = [1.0, 1.7, 0.6];
29
30const DEFAULT_OFFSETS: [f32; 3] = [0.5, 0.35, 0.65];
32
33const DRIFT_PERIOD_MS: f32 = 20_000.0;
35
36#[must_use]
44#[derive(Debug, Clone)]
45pub struct SkeletonLineChart<'a> {
46 elapsed_ms: u64,
47 mode: AnimationMode,
48 base: Color,
49 highlight: Color,
50 lines: u16,
51 filled: bool,
52 block: Option<ratatui_widgets::block::Block<'a>>,
53}
54
55impl<'a> SkeletonLineChart<'a> {
56 pub fn new(elapsed_ms: u64) -> Self {
57 Self {
58 elapsed_ms,
59 mode: AnimationMode::default(),
60 base: defaults::BASE,
61 highlight: defaults::HIGHLIGHT,
62 lines: 2,
63 filled: true,
64 block: None,
65 }
66 }
67
68 pub fn mode(mut self, mode: AnimationMode) -> Self {
69 self.mode = mode;
70 self
71 }
72
73 pub fn base(mut self, color: impl Into<Color>) -> Self {
74 self.base = color.into();
75 self
76 }
77
78 pub fn highlight(mut self, color: impl Into<Color>) -> Self {
79 self.highlight = color.into();
80 self
81 }
82
83 pub fn lines(mut self, lines: u16) -> Self {
85 self.lines = lines;
86 self
87 }
88
89 pub fn filled(mut self, filled: bool) -> Self {
95 self.filled = filled;
96 self
97 }
98
99 pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
100 self.block = Some(block);
101 self
102 }
103}
104
105struct Coloring {
107 mode: AnimationMode,
108 elapsed_ms: u64,
109 base: Color,
110 highlight: Color,
111 breathe_t: Option<f32>,
112}
113
114impl Coloring {
115 fn color_at(&self, col: u16, width: u16) -> Color {
116 let t = self
117 .breathe_t
118 .unwrap_or_else(|| cell_intensity(self.mode, self.elapsed_ms, col, width));
119 interpolate_color(self.base, self.highlight, self.mode, t)
120 }
121}
122
123impl Widget for SkeletonLineChart<'_> {
124 fn render(self, area: Rect, buf: &mut Buffer) {
125 let inner = if let Some(ref block) = self.block {
126 let inner_area = block.inner(area);
127 block.render(area, buf);
128 inner_area
129 } else {
130 area
131 };
132
133 if inner.is_empty() {
134 return;
135 }
136
137 let pixel_w = inner.width as usize * 2;
138 let pixel_h = inner.height as usize * 4;
139 let line_count = self.lines.min(DEFAULT_AMPLITUDES.len() as u16) as usize;
140
141 let drift = self.elapsed_ms as f32 / DRIFT_PERIOD_MS * std::f32::consts::TAU;
143
144 let mut fill_top = vec![pixel_h; pixel_w];
146
147 let mut dots = vec![vec![false; pixel_w]; pixel_h];
149
150 for line_idx in 0..line_count {
151 let amplitude = DEFAULT_AMPLITUDES[line_idx];
152 let freq = DEFAULT_FREQUENCIES[line_idx];
153 let offset = DEFAULT_OFFSETS[line_idx];
154 let line_drift = drift * (1.0 + line_idx as f32 * 0.3);
155
156 plot_wave(
157 &mut dots,
158 &mut fill_top,
159 pixel_w,
160 pixel_h,
161 amplitude,
162 freq,
163 offset,
164 line_drift,
165 );
166 }
167
168 let coloring = Coloring {
169 mode: self.mode,
170 elapsed_ms: self.elapsed_ms,
171 base: self.base,
172 highlight: self.highlight,
173 breathe_t: matches!(self.mode, AnimationMode::Breathe)
174 .then(|| cell_intensity(self.mode, self.elapsed_ms, 0, inner.width)),
175 };
176
177 if self.filled {
178 render_fill(inner, buf, &fill_top, pixel_h, &coloring);
179 }
180
181 render_braille(inner, buf, &dots, pixel_w, pixel_h, &coloring);
182 }
183}
184
185fn render_fill(
187 inner: Rect,
188 buf: &mut Buffer,
189 fill_top: &[usize],
190 pixel_h: usize,
191 color: &Coloring,
192) {
193 for cx in 0..inner.width as usize {
194 let top_pixel = fill_top
195 .get(cx * 2)
196 .copied()
197 .unwrap_or(pixel_h)
198 .min(fill_top.get(cx * 2 + 1).copied().unwrap_or(pixel_h));
199
200 let fill_start = top_pixel / 4 + 1;
201 let col = cx as u16;
202 let fg = color.color_at(col, inner.width);
203
204 for cy in fill_start..inner.height as usize {
205 let x = inner.x + col;
206 let y = inner.y + cy as u16;
207
208 buf[(x, y)].set_char('█').set_style(Style::default().fg(fg));
209 }
210 }
211}
212
213fn render_braille(
215 inner: Rect,
216 buf: &mut Buffer,
217 dots: &[Vec<bool>],
218 pixel_w: usize,
219 pixel_h: usize,
220 color: &Coloring,
221) {
222 for cy in 0..inner.height as usize {
223 for cx in 0..inner.width as usize {
224 let mut pattern: u8 = 0;
225
226 for (dx, dot_col) in DOT.iter().enumerate() {
227 for (dy, &bit) in dot_col.iter().enumerate() {
228 let px = cx * 2 + dx;
229 let py = cy * 4 + dy;
230
231 if px < pixel_w && py < pixel_h && dots[py][px] {
232 pattern |= bit;
233 }
234 }
235 }
236
237 if pattern == 0 {
238 continue;
239 }
240
241 let braille = char::from_u32(BRAILLE_BLANK + pattern as u32).unwrap_or('⠀');
242 let col = cx as u16;
243 let fg = color.color_at(col, inner.width);
244
245 let x = inner.x + col;
246 let y = inner.y + cy as u16;
247 buf[(x, y)]
248 .set_symbol(&braille.to_string())
249 .set_style(Style::default().fg(fg));
250 }
251 }
252}
253
254#[expect(clippy::too_many_arguments)]
256fn plot_wave(
257 dots: &mut [Vec<bool>],
258 fill_top: &mut [usize],
259 pixel_w: usize,
260 pixel_h: usize,
261 amplitude: f32,
262 freq: f32,
263 offset: f32,
264 drift: f32,
265) {
266 let mut prev_y: Option<usize> = None;
267
268 for px in 0..pixel_w {
269 let phase = px as f32 / pixel_w as f32 * std::f32::consts::TAU * freq + drift;
270 let normalized = offset + amplitude * 0.5 * phase.sin();
271 let py = ((1.0 - normalized.clamp(0.0, 1.0)) * (pixel_h - 1) as f32) as usize;
272
273 dots[py][px] = true;
274 fill_top[px] = fill_top[px].min(py);
275
276 if let Some(prev) = prev_y {
278 let (lo, hi) = if prev < py { (prev, py) } else { (py, prev) };
279
280 for row in dots.iter_mut().take(hi + 1).skip(lo) {
281 row[px] = true;
282 }
283 }
284
285 prev_y = Some(py);
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn renders_braille_characters() {
295 let area = Rect::new(0, 0, 20, 5);
296 let mut buf = Buffer::empty(area);
297
298 SkeletonLineChart::new(1000).lines(1).render(area, &mut buf);
299
300 let has_braille = (0..20)
301 .flat_map(|x| (0..5).map(move |y| (x, y)))
302 .any(|(x, y)| {
303 let sym = buf[(x as u16, y as u16)].symbol();
304 sym.chars()
305 .next()
306 .is_some_and(|c| (0x2800..=0x28FF).contains(&(c as u32)))
307 });
308
309 assert!(has_braille, "expected braille characters in output");
310 }
311
312 #[test]
313 fn filled_area_below_wave() {
314 let area = Rect::new(0, 0, 20, 10);
315 let mut buf = Buffer::empty(area);
316
317 SkeletonLineChart::new(1000)
318 .lines(1)
319 .filled(true)
320 .render(area, &mut buf);
321
322 let bottom_filled = (0..20).any(|x| buf[(x as u16, 9u16)].symbol() == "█");
324 assert!(bottom_filled, "bottom row should have fill");
325 }
326
327 #[test]
328 fn unfilled_has_no_blocks() {
329 let area = Rect::new(0, 0, 20, 10);
330 let mut buf = Buffer::empty(area);
331
332 SkeletonLineChart::new(1000)
333 .lines(1)
334 .filled(false)
335 .render(area, &mut buf);
336
337 let has_block = (0..20)
338 .flat_map(|x| (0..10).map(move |y| (x, y)))
339 .any(|(x, y)| buf[(x as u16, y as u16)].symbol() == "█");
340
341 assert!(!has_block, "unfilled mode should have no █ characters");
342 }
343
344 #[test]
345 fn drift_changes_output() {
346 let area = Rect::new(0, 0, 20, 5);
347 let mut buf_a = Buffer::empty(area);
348 let mut buf_b = Buffer::empty(area);
349
350 SkeletonLineChart::new(0).lines(1).render(area, &mut buf_a);
351 SkeletonLineChart::new(5000)
352 .lines(1)
353 .render(area, &mut buf_b);
354
355 assert_ne!(
356 buf_a, buf_b,
357 "different timestamps should produce different output"
358 );
359 }
360
361 #[test]
362 fn empty_area_is_noop() {
363 let area = Rect::new(0, 0, 0, 0);
364 let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
365 let expected = buf.clone();
366
367 SkeletonLineChart::new(0).render(area, &mut buf);
368
369 assert_eq!(buf, expected);
370 }
371}