Skip to main content

tui_skeleton/
line_chart.rs

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
11/// Braille dot offsets within a 2×4 cell.
12///
13/// ```text
14/// (0,0)=0x01  (1,0)=0x08
15/// (0,1)=0x02  (1,1)=0x10
16/// (0,2)=0x04  (1,2)=0x20
17/// (0,3)=0x40  (1,3)=0x80
18/// ```
19const DOT: [[u8; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
20
21/// Braille blank character (U+2800).
22const BRAILLE_BLANK: u32 = 0x2800;
23
24/// Deterministic wave amplitudes for layered lines.
25const DEFAULT_AMPLITUDES: [f32; 3] = [0.7, 0.45, 0.85];
26
27/// Deterministic frequency multipliers for layered lines.
28const DEFAULT_FREQUENCIES: [f32; 3] = [1.0, 1.7, 0.6];
29
30/// Deterministic vertical offsets for layered lines.
31const DEFAULT_OFFSETS: [f32; 3] = [0.5, 0.35, 0.65];
32
33/// Wave drift speed: one full pixel-width scroll per 20s.
34const DRIFT_PERIOD_MS: f32 = 20_000.0;
35
36/// Skeleton line chart rendered with braille traces over filled area.
37///
38/// Generates deterministic sine-wave paths that drift over time,
39/// rendered as braille dot traces with solid `█` fill below each
40/// line. The filled area makes skeleton animations (Breathe, Sweep,
41/// Plasma) clearly visible, while the braille edge gives the chart
42/// its line-chart silhouette.
43#[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    /// Number of overlapping line traces. Default: `2`.
84    pub fn lines(mut self, lines: u16) -> Self {
85        self.lines = lines;
86        self
87    }
88
89    /// Fill the area below each line with `█`. Default: `true`.
90    ///
91    /// When enabled, the filled region carries the skeleton animation
92    /// (Breathe/Sweep/Plasma) while the braille trace sits on top as
93    /// the edge. Disable for line-only rendering.
94    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
105/// Bundles animation parameters shared across rendering passes.
106struct 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        // Time-based phase drift so lines undulate.
142        let drift = self.elapsed_ms as f32 / DRIFT_PERIOD_MS * std::f32::consts::TAU;
143
144        // Track the highest wave (lowest y-pixel) at each column for fill.
145        let mut fill_top = vec![pixel_h; pixel_w];
146
147        // Build braille dot grid for the line traces.
148        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
185/// Render solid `█` fill from each column's wave-top down to the bottom.
186fn 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
213/// Encode dot grid into braille characters with animation color.
214fn 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/// Plot a drifting sine wave onto the dot grid and update fill envelope.
255#[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        // Connect to previous pixel vertically to avoid gaps.
277        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        // Bottom row should be filled (wave never sits at the very bottom).
323        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}