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::{AnimationMode, cell_intensity, interpolate_color, is_uniform};
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    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    /// Override the timestamp used for wave drift.
73    ///
74    /// When set, the wave shape is computed from this fixed value
75    /// while color animation still uses `elapsed_ms`. Pass `0` to
76    /// freeze the wave in place.
77    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    /// Number of overlapping line traces. Default: `2`.
103    pub fn lines(mut self, lines: u16) -> Self {
104        self.lines = lines;
105        self
106    }
107
108    /// Fill the area below each line with `█`. Default: `true`.
109    ///
110    /// When enabled, the filled region carries the skeleton animation
111    /// (Breathe/Sweep/Plasma) while the braille trace sits on top as
112    /// the edge. Disable for line-only rendering.
113    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
124/// Bundles animation parameters shared across rendering passes.
125struct 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        // Track the highest wave (lowest y-pixel) at each column for fill.
165        let mut fill_top = vec![pixel_h; pixel_w];
166
167        // Build braille dot grid for the line traces.
168        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
206/// Render solid `█` fill from each column's wave-top down to the bottom.
207fn 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
243/// Encode dot grid into braille characters with animation color.
244fn 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/// Plot a drifting sine wave onto the dot grid and update fill envelope.
285#[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        // Connect to previous pixel vertically to avoid gaps.
307        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        // Bottom row should be filled (wave never sits at the very bottom).
357        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}