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