Skip to main content

tui_skeleton/
streaming_text.rs

1use ratatui_core::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
2
3use crate::animation::AnimationMode;
4use crate::block::render_skeleton_cells;
5use crate::defaults;
6
7/// Default line-width fractions simulating paragraph variance.
8const DEFAULT_LINE_WIDTHS: [f32; 7] = [0.90, 0.85, 0.92, 0.78, 0.88, 0.80, 0.55];
9
10/// Lines never exceed this fraction of the container width.
11const MAX_WIDTH_FRAC: f32 = 0.95;
12
13/// Default duration for the typewriter fill to complete.
14const DEFAULT_DURATION_MS: u64 = 3000;
15
16/// Skeleton simulating streaming chat text.
17///
18/// Cells fill left-to-right, top-to-bottom over `duration_ms`, mimicking
19/// text being typed into a chat bubble. Once the fill completes, all
20/// lines remain visible and the animation mode pulses normally.
21///
22/// Line widths are deterministic fractions that cycle, producing a
23/// ragged paragraph shape.
24#[must_use]
25#[derive(Debug, Clone)]
26pub struct SkeletonStreamingText<'a> {
27    elapsed_ms: u64,
28    mode: AnimationMode,
29    braille: bool,
30    base: Color,
31    highlight: Color,
32    lines: u16,
33    duration_ms: u64,
34    repeat: bool,
35    line_widths: &'a [f32],
36    block: Option<ratatui_widgets::block::Block<'a>>,
37}
38
39impl<'a> SkeletonStreamingText<'a> {
40    pub fn new(elapsed_ms: u64) -> Self {
41        Self {
42            elapsed_ms,
43            mode: AnimationMode::default(),
44            braille: false,
45            base: defaults::BASE,
46            highlight: defaults::HIGHLIGHT,
47            lines: 5,
48            duration_ms: DEFAULT_DURATION_MS,
49            repeat: false,
50            line_widths: &DEFAULT_LINE_WIDTHS,
51            block: None,
52        }
53    }
54
55    pub fn mode(mut self, mode: AnimationMode) -> Self {
56        self.mode = mode;
57        self
58    }
59
60    pub fn braille(mut self, braille: bool) -> Self {
61        self.braille = braille;
62        self
63    }
64
65    pub fn base(mut self, color: impl Into<Color>) -> Self {
66        self.base = color.into();
67        self
68    }
69
70    pub fn highlight(mut self, color: impl Into<Color>) -> Self {
71        self.highlight = color.into();
72        self
73    }
74
75    /// Total lines of text to fill. Default: `5`.
76    pub fn lines(mut self, lines: u16) -> Self {
77        self.lines = lines;
78        self
79    }
80
81    /// Duration in milliseconds for the typewriter to fill all lines. Default: `3000`.
82    pub fn duration_ms(mut self, ms: u64) -> Self {
83        self.duration_ms = ms;
84        self
85    }
86
87    /// Loop the typewriter fill. When true, the fill resets after
88    /// `duration_ms` plus a hold period and replays. Default: `false`.
89    pub fn repeat(mut self, repeat: bool) -> Self {
90        self.repeat = repeat;
91        self
92    }
93
94    /// Per-line width fractions (`0.0..=1.0`), cycling for more lines.
95    pub fn line_widths(mut self, widths: &'a [f32]) -> Self {
96        self.line_widths = widths;
97        self
98    }
99
100    pub fn block(mut self, block: ratatui_widgets::block::Block<'a>) -> Self {
101        self.block = Some(block);
102        self
103    }
104}
105
106impl Widget for SkeletonStreamingText<'_> {
107    fn render(self, area: Rect, buf: &mut Buffer) {
108        let inner = if let Some(ref block) = self.block {
109            let inner_area = block.inner(area);
110            block.render(area, buf);
111            inner_area
112        } else {
113            area
114        };
115
116        if inner.is_empty() || self.line_widths.is_empty() || self.lines == 0 {
117            return;
118        }
119
120        let total_width = inner.width;
121        let render_lines = self.lines.min(inner.height);
122        let widths = self.line_widths;
123
124        // Compute per-line cell counts, then total cells across all lines.
125        let line_cells: Vec<u16> = (0..render_lines)
126            .map(|row| {
127                let frac = widths[row as usize % widths.len()].clamp(0.0, MAX_WIDTH_FRAC);
128                (total_width as f32 * frac) as u16
129            })
130            .collect();
131
132        let total_cells: u64 = line_cells.iter().map(|&w| w as u64).sum();
133
134        // When repeating, cycle through fill + hold, then reset.
135        let hold_ms = self.duration_ms * 2 / 3;
136        let cycle_ms = self.duration_ms + hold_ms;
137        let effective_ms = if self.repeat {
138            self.elapsed_ms % cycle_ms
139        } else {
140            self.elapsed_ms
141        };
142
143        let progress = if self.duration_ms == 0 || effective_ms >= self.duration_ms {
144            total_cells
145        } else {
146            total_cells * effective_ms / self.duration_ms
147        };
148
149        // Cumulative cell count — determines which cells are filled.
150        let mut cumulative = 0u64;
151        let mut line_starts: Vec<u64> = Vec::with_capacity(render_lines as usize);
152
153        for &cells in &line_cells {
154            line_starts.push(cumulative);
155            cumulative += cells as u64;
156        }
157
158        render_skeleton_cells(
159            Rect::new(inner.x, inner.y, inner.width, render_lines),
160            buf,
161            self.mode,
162            self.braille,
163            self.elapsed_ms,
164            self.base,
165            self.highlight,
166            |row, col, _width| {
167                let row_idx = row as usize;
168
169                if row_idx >= line_cells.len() {
170                    return false;
171                }
172
173                let line_width = line_cells[row_idx];
174
175                if col >= line_width {
176                    return false;
177                }
178
179                // Cell's absolute position in the typewriter sequence.
180                let cell_pos = line_starts[row_idx] + col as u64;
181                cell_pos < progress
182            },
183        );
184    }
185}
186
187#[cfg(feature = "pantry")]
188#[path = "streaming_text.ingredient.rs"]
189pub mod ingredient;
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn no_cells_at_zero() {
197        let area = Rect::new(0, 0, 20, 5);
198        let mut buf = Buffer::empty(area);
199
200        SkeletonStreamingText::new(0)
201            .lines(5)
202            .duration_ms(1000)
203            .render(area, &mut buf);
204
205        for y in 0..5 {
206            for x in 0..20 {
207                assert_eq!(buf[(x, y)].symbol(), " ");
208            }
209        }
210    }
211
212    #[test]
213    fn all_cells_after_duration() {
214        let area = Rect::new(0, 0, 20, 3);
215        let mut buf = Buffer::empty(area);
216
217        // 0.95 cap → 19 of 20 cols filled per line.
218        SkeletonStreamingText::new(5000)
219            .lines(3)
220            .duration_ms(1000)
221            .line_widths(&[1.0])
222            .render(area, &mut buf);
223
224        for y in 0..3u16 {
225            assert_eq!(buf[(18, y)].symbol(), "█");
226            assert_eq!(buf[(19, y)].symbol(), " ");
227        }
228    }
229
230    #[test]
231    fn partial_fill_first_line() {
232        let area = Rect::new(0, 0, 20, 2);
233        let mut buf = Buffer::empty(area);
234
235        // 0.5 × 20 = 10 cols per line, 2 lines = 20 total cells.
236        // At 500/1000 = 50%, 10 cells filled = first line.
237        SkeletonStreamingText::new(500)
238            .lines(2)
239            .duration_ms(1000)
240            .line_widths(&[0.5])
241            .render(area, &mut buf);
242
243        // First line fully filled (10 cols).
244        for x in 0..10 {
245            assert_eq!(buf[(x, 0u16)].symbol(), "█");
246        }
247
248        // Second line empty.
249        for x in 0..10 {
250            assert_eq!(buf[(x, 1u16)].symbol(), " ");
251        }
252    }
253
254    #[test]
255    fn ragged_widths_respected() {
256        let area = Rect::new(0, 0, 20, 2);
257        let mut buf = Buffer::empty(area);
258
259        // Line 0: 50% = 10 cols, line 1: 90% = 18 cols (under 0.95 cap).
260        SkeletonStreamingText::new(2000)
261            .lines(2)
262            .duration_ms(1000)
263            .line_widths(&[0.5, 0.9])
264            .render(area, &mut buf);
265
266        // Line 0: cols 0-9 filled, col 10 empty.
267        assert_eq!(buf[(9, 0u16)].symbol(), "█");
268        assert_eq!(buf[(10, 0u16)].symbol(), " ");
269
270        // Line 1: col 17 filled, col 18 empty.
271        assert_eq!(buf[(17, 1u16)].symbol(), "█");
272        assert_eq!(buf[(18, 1u16)].symbol(), " ");
273    }
274}