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