1use std::{
4 collections::VecDeque,
5 time::{Duration, Instant},
6};
7
8use gw_parse::Progress;
9use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::{Color, Modifier, Style},
13 widgets::Widget,
14};
15
16const FILL_BG: Color = Color::Indexed(28);
18const EMPTY_BG: Color = Color::Indexed(237);
20use throbber_widgets_tui::{BRAILLE_SIX, Throbber, ThrobberState};
21
22const RATE: f32 = 400.0;
24const HOLD: Duration = Duration::from_millis(120);
26
27pub struct Animator {
30 shown: Option<Progress>,
31 fill: f32,
32 since: Instant,
33 last: Instant,
34 queue: VecDeque<Progress>,
35}
36
37fn same_stage(a: &Progress, b: &Progress) -> bool {
38 a.label == b.label && a.remote == b.remote
39}
40
41impl Animator {
42 pub fn new(now: Instant) -> Self {
43 Self { shown: None, fill: 0.0, since: now, last: now, queue: VecDeque::new() }
44 }
45
46 pub fn push(&mut self, p: Progress, now: Instant) {
48 if let Some(b) = self.queue.back_mut().filter(|b| same_stage(b, &p)) {
49 *b = p;
50 return;
51 }
52 match &mut self.shown {
53 Some(s) if self.queue.is_empty() && same_stage(s, &p) => *s = p,
54 Some(_) => self.queue.push_back(p),
55 None => {
56 self.shown = Some(p);
57 self.fill = 0.0;
58 self.since = now;
59 }
60 }
61 }
62
63 fn target(&self) -> f32 {
64 self.shown.as_ref().and_then(|s| s.pct).map_or(0.0, f32::from)
65 }
66
67 pub fn tick(&mut self, now: Instant) {
69 let dt = now.saturating_duration_since(self.last).as_secs_f32();
70 self.last = now;
71 self.fill = (self.fill + RATE * dt).min(self.target());
72 if self.fill >= self.target()
73 && now.saturating_duration_since(self.since) >= HOLD
74 && let Some(next) = self.queue.pop_front()
75 {
76 self.shown = Some(next);
77 self.fill = 0.0;
78 self.since = now;
79 }
80 }
81
82 pub fn current(&self) -> Option<(&Progress, u8)> {
84 self.shown.as_ref().map(|s| (s, self.fill as u8))
85 }
86
87 pub fn latest(&self) -> Option<&Progress> {
89 self.queue.back().or(self.shown.as_ref())
90 }
91
92 pub fn settled(&self) -> bool {
94 self.queue.is_empty() && self.fill >= self.target()
95 }
96}
97
98const LABEL_W: usize = 19;
100
101pub struct ProgressLine<'a> {
102 pub progress: &'a Progress,
103 pub fill: Option<u8>,
105 pub throbber: &'a ThrobberState,
106 pub done: Option<bool>,
108 pub color: bool,
109}
110
111impl Widget for ProgressLine<'_> {
112 fn render(self, area: Rect, buf: &mut Buffer) {
113 if area.width < 6 || area.height == 0 {
114 return;
115 }
116 let y = area.y;
117 let st = |c: Color| if self.color { Style::default().fg(c) } else { Style::default() };
118 let (mark, mark_style) = match self.done {
119 Some(true) => ("✔".to_string(), st(Color::Green)),
120 Some(false) => ("✘".to_string(), st(Color::Red)),
121 None => {
122 let span = Throbber::default().throbber_set(BRAILLE_SIX).to_symbol_span(self.throbber);
123 (span.content.trim().to_string(), Style::default())
124 }
125 };
126 buf.set_string(area.x, y, &mark, mark_style);
127
128 let p = self.progress;
129 let mut bar_x = area.x + 2;
131 if area.width >= 40 && !p.label.is_empty() {
132 let max = (area.width / 2) as usize;
133 let label: String = p.label.chars().take(max).collect();
134 buf.set_string(bar_x, y, &label, Style::default());
135 bar_x += label.chars().count().max(LABEL_W).min(max) as u16 + 1;
136 }
137 buf.set_string(bar_x, y, "[", Style::default());
138 buf.set_string(area.x + area.width - 1, y, "]", Style::default());
139
140 let x0 = bar_x + 1;
141 let w = (area.x + area.width - 1).saturating_sub(x0) as usize;
142 let fill = self.fill.or(p.pct).map_or(0, |pct| w * pct.min(100) as usize / 100);
143 let right = match (p.cur, p.total, p.pct) {
144 (Some(c), Some(t), Some(pct)) if area.width >= 40 => format!("{c}/{t} {pct}%"),
145 (_, _, Some(pct)) => format!("{pct}%"),
146 (Some(c), _, None) if area.width >= 40 => c.to_string(),
147 _ => String::new(),
148 };
149
150 let mut text: Vec<Option<char>> = vec![None; w];
152 let right_start = w.saturating_sub(right.chars().count() + 1);
153 for (i, c) in right.chars().enumerate() {
154 if right_start + i < w {
155 text[right_start + i] = Some(c);
156 }
157 }
158 for (i, t) in text.iter().enumerate() {
159 let filled = i < fill;
160 let (sym, style) = match (self.color, t) {
161 (true, _) => {
162 let style = if filled {
164 Style::default().bg(FILL_BG).fg(Color::Indexed(231)).add_modifier(Modifier::BOLD)
165 } else {
166 Style::default().bg(EMPTY_BG).fg(Color::Indexed(252))
167 };
168 (t.unwrap_or(' '), style)
169 }
170 (false, Some(c)) => (*c, Style::default()),
171 (false, None) => (if filled { '#' } else { '.' }, Style::default()),
172 };
173 buf[(x0 + i as u16, y)].set_char(sym).set_style(style);
174 }
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use ratatui::{Terminal, backend::TestBackend};
182
183 fn draw(width: u16, pct: Option<u8>, cur: u64, done: Option<bool>, color: bool) -> Buffer {
184 let p = Progress { label: "Writing objects".into(), cur: Some(cur), total: Some(9), pct, ..Default::default() };
185 let mut t = Terminal::new(TestBackend::new(width, 1)).unwrap();
186 let th = ThrobberState::default();
187 t.draw(|f| f.render_widget(ProgressLine { progress: &p, fill: None, throbber: &th, done, color }, f.area())).unwrap();
188 t.backend().buffer().clone()
189 }
190
191 fn line(b: &Buffer) -> String {
192 b.content.iter().map(|c| c.symbol()).collect()
193 }
194
195 fn bg(b: &Buffer, x: u16) -> Option<Color> {
196 b[(x, 0)].style().bg
197 }
198
199 fn stage(label: &str, pct: u8) -> Progress {
200 Progress { label: label.into(), pct: Some(pct), ..Default::default() }
201 }
202
203 #[test]
204 fn animator_plays_instant_stages_in_order() {
205 let t0 = Instant::now();
206 let ms = |n| t0 + Duration::from_millis(n);
207 let mut a = Animator::new(t0);
208 for (i, p) in [stage("Compressing objects", 50), stage("Compressing objects", 100), stage("Writing objects", 100)].into_iter().enumerate() {
209 a.push(p, ms(i as u64));
210 }
211 a.push(stage("Resolving deltas", 100), ms(3));
212 assert_eq!(a.latest().unwrap().label, "Resolving deltas");
213 a.tick(ms(100));
214 let (p, fill) = a.current().unwrap();
215 assert_eq!((p.label.as_str(), fill), ("Compressing objects", 40), "merged to 100%, eased 400%/s");
216 a.tick(ms(260));
217 assert_eq!(a.current().unwrap().0.label, "Writing objects", "caught up and held -> next stage");
218 assert_eq!(a.current().unwrap().1, 0);
219 a.tick(ms(520));
220 assert_eq!(a.current().unwrap().0.label, "Resolving deltas");
221 assert!(!a.settled());
222 a.tick(ms(800));
223 assert!(a.settled());
224 }
225
226 #[test]
229 fn fill_override() {
230 let p = stage("Writing objects", 100);
231 let mut t = Terminal::new(TestBackend::new(60, 1)).unwrap();
232 let th = ThrobberState::default();
233 t.draw(|f| f.render_widget(ProgressLine { progress: &p, fill: Some(50), throbber: &th, done: None, color: true }, f.area())).unwrap();
234 let b = t.backend().buffer();
235 assert_eq!(bg(b, 40), Some(FILL_BG));
236 assert_eq!(bg(b, 41), Some(EMPTY_BG));
237 assert!(line(b).contains("100%"), "counters show git's real value");
238 }
239
240 #[test]
241 fn zero_percent() {
242 let b = draw(60, Some(0), 0, None, true);
243 assert!(line(&b).contains(" Writing objects [ "), "{}", line(&b));
244 assert!(line(&b).ends_with("0/9 0% ]"));
245 assert_eq!(bg(&b, 23), Some(EMPTY_BG));
246 assert_ne!(bg(&b, 3), Some(EMPTY_BG), "label has no bar background");
247 }
248
249 #[test]
250 fn forty_four_percent() {
251 let b = draw(60, Some(44), 4, None, true);
252 assert_eq!(bg(&b, 37), Some(FILL_BG));
254 assert_eq!(bg(&b, 38), Some(EMPTY_BG));
255 assert!(line(&b).contains("4/9 44%"));
256 let plain = line(&draw(60, Some(44), 4, None, false));
257 assert!(plain.contains(" Writing objects [###############....."), "{plain}");
258 assert!(plain.ends_with("....4/9 44%.]"), "{plain}");
259 }
260
261 #[test]
262 fn label_column_is_fixed() {
263 let bracket = |label: &str| {
264 let p = stage(label, 10);
265 let mut t = Terminal::new(TestBackend::new(60, 1)).unwrap();
266 let th = ThrobberState::default();
267 t.draw(|f| f.render_widget(ProgressLine { progress: &p, fill: None, throbber: &th, done: None, color: false }, f.area())).unwrap();
268 line(t.backend().buffer()).find('[').unwrap()
269 };
270 assert_eq!(bracket("Counting objects"), bracket("Compressing objects"));
271 assert!(bracket("Checking connectivity of a long name") > bracket("Counting objects"), "long labels push the bar");
272 }
273
274 #[test]
275 fn full_success() {
276 let b = draw(60, Some(100), 9, Some(true), true);
277 assert!(line(&b).starts_with("✔ Writing objects ["), "{}", line(&b));
278 assert!(line(&b).ends_with("9/9 100% ]"));
279 assert_eq!(b[(0, 0)].style().fg, Some(Color::Green));
280 assert_eq!(bg(&b, 58), Some(FILL_BG));
281 }
282
283 #[test]
284 fn failed_keeps_progress() {
285 let b = draw(60, Some(44), 4, Some(false), true);
286 assert!(line(&b).starts_with("✘"));
287 assert_eq!(b[(0, 0)].style().fg, Some(Color::Red));
288 assert_eq!(bg(&b, 37), Some(FILL_BG));
289 assert_eq!(bg(&b, 38), Some(EMPTY_BG));
290 }
291
292 #[test]
293 fn narrow_drops_label() {
294 let s = line(&draw(30, Some(44), 4, None, false));
295 assert_eq!(s.chars().count(), 30);
296 assert!(!s.contains("Writing"));
297 assert!(s.ends_with("44%.]"), "{s}");
298 assert!(s.contains("[###"), "{s}");
299 }
300}