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 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 pub fn lines(mut self, lines: u16) -> Self {
77 self.lines = lines;
78 self
79 }
80
81 pub fn duration_ms(mut self, ms: u64) -> Self {
83 self.duration_ms = ms;
84 self
85 }
86
87 pub fn repeat(mut self, repeat: bool) -> Self {
90 self.repeat = repeat;
91 self
92 }
93
94 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 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 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 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 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 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 SkeletonStreamingText::new(500)
238 .lines(2)
239 .duration_ms(1000)
240 .line_widths(&[0.5])
241 .render(area, &mut buf);
242
243 for x in 0..10 {
245 assert_eq!(buf[(x, 0u16)].symbol(), "█");
246 }
247
248 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 SkeletonStreamingText::new(2000)
261 .lines(2)
262 .duration_ms(1000)
263 .line_widths(&[0.5, 0.9])
264 .render(area, &mut buf);
265
266 assert_eq!(buf[(9, 0u16)].symbol(), "█");
268 assert_eq!(buf[(10, 0u16)].symbol(), " ");
269
270 assert_eq!(buf[(17, 1u16)].symbol(), "█");
272 assert_eq!(buf[(18, 1u16)].symbol(), " ");
273 }
274}