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
7const DEFAULT_LINE_WIDTHS: [f32; 7] = [0.90, 0.85, 0.92, 0.78, 0.88, 0.80, 0.55];
9
10const MAX_WIDTH_FRAC: f32 = 0.95;
12
13const DEFAULT_DURATION_MS: u64 = 3000;
15
16#[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 pub fn lines(mut self, lines: u16) -> Self {
70 self.lines = lines;
71 self
72 }
73
74 pub fn duration_ms(mut self, ms: u64) -> Self {
76 self.duration_ms = ms;
77 self
78 }
79
80 pub fn repeat(mut self, repeat: bool) -> Self {
83 self.repeat = repeat;
84 self
85 }
86
87 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 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 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 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 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 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 SkeletonStreamingText::new(500)
230 .lines(2)
231 .duration_ms(1000)
232 .line_widths(&[0.5])
233 .render(area, &mut buf);
234
235 for x in 0..10 {
237 assert_eq!(buf[(x, 0u16)].symbol(), "█");
238 }
239
240 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 SkeletonStreamingText::new(2000)
253 .lines(2)
254 .duration_ms(1000)
255 .line_widths(&[0.5, 0.9])
256 .render(area, &mut buf);
257
258 assert_eq!(buf[(9, 0u16)].symbol(), "█");
260 assert_eq!(buf[(10, 0u16)].symbol(), " ");
261
262 assert_eq!(buf[(17, 1u16)].symbol(), "█");
264 assert_eq!(buf[(18, 1u16)].symbol(), " ");
265 }
266}