prodash/render/tui/draw/
progress.rs

1use std::{fmt, sync::atomic::Ordering, time::Duration};
2
3use tui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Color, Modifier, Style},
7};
8use tui_react::fill_background;
9
10use crate::{
11    progress::{self, Key, Step, Task, Value},
12    render::tui::{
13        draw::State,
14        utils::{
15            block_width, draw_text_nowrap_fn, draw_text_with_ellipsis_nowrap, rect, sanitize_offset,
16            GraphemeCountWriter, VERTICAL_LINE,
17        },
18        InterruptDrawInfo,
19    },
20    time::format_now_datetime_seconds,
21    unit, Throughput,
22};
23
24const MIN_TREE_WIDTH: u16 = 20;
25
26pub fn pane(entries: &[(Key, progress::Task)], mut bound: Rect, buf: &mut Buffer, state: &mut State) {
27    state.task_offset = sanitize_offset(state.task_offset, entries.len(), bound.height);
28    let needs_overflow_line =
29        if entries.len() > bound.height as usize || (state.task_offset).min(entries.len() as u16) > 0 {
30            bound.height = bound.height.saturating_sub(1);
31            true
32        } else {
33            false
34        };
35    state.task_offset = sanitize_offset(state.task_offset, entries.len(), bound.height);
36
37    if entries.is_empty() {
38        return;
39    }
40
41    let initial_column_width = bound.width / 3;
42    let desired_max_tree_draw_width = *state.next_tree_column_width.as_ref().unwrap_or(&initial_column_width);
43    {
44        if initial_column_width >= MIN_TREE_WIDTH {
45            let tree_bound = Rect {
46                width: desired_max_tree_draw_width,
47                ..bound
48            };
49            let computed = draw_tree(entries, buf, tree_bound, state.task_offset);
50            state.last_tree_column_width = Some(computed);
51        } else {
52            state.last_tree_column_width = Some(0);
53        };
54    }
55
56    {
57        if let Some(tp) = state.throughput.as_mut() {
58            tp.update_elapsed();
59        }
60
61        let progress_area = rect::offset_x(bound, desired_max_tree_draw_width);
62        draw_progress(
63            entries,
64            buf,
65            progress_area,
66            state.task_offset,
67            state.throughput.as_mut(),
68        );
69
70        if let Some(tp) = state.throughput.as_mut() {
71            tp.reconcile(entries);
72        }
73    }
74
75    if needs_overflow_line {
76        let overflow_rect = Rect {
77            y: bound.height + 1,
78            height: 1,
79            ..bound
80        };
81        draw_overflow(
82            entries,
83            buf,
84            overflow_rect,
85            desired_max_tree_draw_width,
86            bound.height,
87            state.task_offset,
88        );
89    }
90}
91
92pub(crate) fn headline(
93    entries: &[(Key, Task)],
94    interrupt_mode: InterruptDrawInfo,
95    duration_per_frame: Duration,
96    buf: &mut Buffer,
97    bound: Rect,
98) {
99    let (num_running_tasks, num_blocked_tasks, num_groups) = entries.iter().fold(
100        (0, 0, 0),
101        |(mut running, mut blocked, mut groups), (_key, Task { progress, .. })| {
102            match progress.as_ref().map(|p| p.state) {
103                Some(progress::State::Running) => running += 1,
104                Some(progress::State::Blocked(_, _)) | Some(progress::State::Halted(_, _)) => blocked += 1,
105                None => groups += 1,
106            }
107            (running, blocked, groups)
108        },
109    );
110    let text = format!(
111        " {} {} {:3} running + {:3} blocked + {:3} groups = {} ",
112        match interrupt_mode {
113            InterruptDrawInfo::Instantly => "'q' or CTRL+c to quit",
114            InterruptDrawInfo::Deferred(interrupt_requested) => {
115                if interrupt_requested {
116                    "interrupt requested - please wait"
117                } else {
118                    "cannot interrupt current operation"
119                }
120            }
121        },
122        if duration_per_frame > Duration::from_secs(1) {
123            format!(
124                " Every {}s → {}",
125                duration_per_frame.as_secs(),
126                format_now_datetime_seconds()
127            )
128        } else {
129            "".into()
130        },
131        num_running_tasks,
132        num_blocked_tasks,
133        num_groups,
134        entries.len()
135    );
136
137    let bold = Style::default().add_modifier(Modifier::BOLD);
138    draw_text_with_ellipsis_nowrap(rect::snap_to_right(bound, block_width(&text) + 1), buf, text, bold);
139}
140
141struct ProgressFormat<'a>(&'a Option<Value>, u16, Option<unit::display::Throughput>);
142
143impl fmt::Display for ProgressFormat<'_> {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self.0 {
146            Some(p) => match p.unit.as_ref() {
147                Some(unit) => write!(
148                    f,
149                    "{}",
150                    unit.display(p.step.load(Ordering::SeqCst), p.done_at, self.2.clone())
151                ),
152                None => match p.done_at {
153                    Some(done_at) => write!(f, "{}/{}", p.step.load(Ordering::SeqCst), done_at),
154                    None => write!(f, "{}", p.step.load(Ordering::SeqCst)),
155                },
156            },
157            None => write!(f, "{:─<width$}", '─', width = self.1 as usize),
158        }
159    }
160}
161
162fn has_child(entries: &[(Key, Task)], index: usize) -> bool {
163    entries
164        .get(index + 1)
165        .and_then(|(other_key, other_val)| {
166            entries.get(index).map(|(cur_key, _)| {
167                cur_key.shares_parent_with(other_key, cur_key.level()) && other_val.progress.is_some()
168            })
169        })
170        .unwrap_or(false)
171}
172
173pub fn draw_progress(
174    entries: &[(Key, Task)],
175    buf: &mut Buffer,
176    bound: Rect,
177    offset: u16,
178    mut throughput: Option<&mut Throughput>,
179) {
180    let title_spacing = 2u16 + 1; // 2 on the left, 1 on the right
181    let max_progress_label_width = entries
182        .iter()
183        .skip(offset as usize)
184        .take(bound.height as usize)
185        .map(|(_, Task { progress, .. })| progress)
186        .fold(0, |state, progress| match progress {
187            progress @ Some(_) => {
188                use std::io::Write;
189                let mut w = GraphemeCountWriter::default();
190                write!(w, "{}", ProgressFormat(progress, 0, None)).expect("never fails");
191                state.max(w.0)
192            }
193            None => state,
194        });
195
196    for (
197        line,
198        (
199            entry_index,
200            (
201                key,
202                Task {
203                    progress,
204                    name: title,
205                    id: _,
206                },
207            ),
208        ),
209    ) in entries
210        .iter()
211        .enumerate()
212        .skip(offset as usize)
213        .take(bound.height as usize)
214        .enumerate()
215    {
216        let throughput = throughput
217            .as_mut()
218            .and_then(|tp| tp.update_and_get(key, progress.as_ref()));
219        let line_bound = rect::line_bound(bound, line);
220        let progress_text = format!(
221            " {progress}",
222            progress = ProgressFormat(
223                progress,
224                if has_child(entries, entry_index) {
225                    bound.width.saturating_sub(title_spacing)
226                } else {
227                    0
228                },
229                throughput
230            )
231        );
232
233        draw_text_with_ellipsis_nowrap(line_bound, buf, VERTICAL_LINE, None);
234
235        let tree_prefix = level_prefix(entries, entry_index);
236        let progress_rect = rect::offset_x(line_bound, block_width(&tree_prefix));
237        draw_text_with_ellipsis_nowrap(line_bound, buf, tree_prefix, None);
238        match progress
239            .as_ref()
240            .map(|p| (p.fraction(), p.state, p.step.load(Ordering::SeqCst)))
241        {
242            Some((Some(fraction), state, _step)) => {
243                let mut progress_text = progress_text;
244                add_block_eta(state, &mut progress_text);
245                let (bound, style) = draw_progress_bar_fn(buf, progress_rect, fraction, |fraction| match state {
246                    progress::State::Blocked(_, _) => Color::Red,
247                    progress::State::Halted(_, _) => Color::LightRed,
248                    progress::State::Running => {
249                        if fraction >= 0.8 {
250                            Color::Green
251                        } else {
252                            Color::Yellow
253                        }
254                    }
255                });
256                let style_fn = move |_t: &str, x: u16, _y: u16| {
257                    if x < bound.right() {
258                        style
259                    } else {
260                        Style::default()
261                    }
262                };
263                draw_text_nowrap_fn(progress_rect, buf, progress_text, style_fn);
264            }
265            Some((None, state, step)) => {
266                let mut progress_text = progress_text;
267                add_block_eta(state, &mut progress_text);
268                draw_text_with_ellipsis_nowrap(progress_rect, buf, progress_text, None);
269                let bar_rect = rect::offset_x(line_bound, max_progress_label_width as u16);
270                draw_spinner(
271                    buf,
272                    bar_rect,
273                    step,
274                    line,
275                    match state {
276                        progress::State::Blocked(_, _) => Color::Red,
277                        progress::State::Halted(_, _) => Color::LightRed,
278                        progress::State::Running => Color::White,
279                    },
280                );
281            }
282            None => {
283                let bold = Style::default().add_modifier(Modifier::BOLD);
284                draw_text_nowrap_fn(progress_rect, buf, progress_text, |_, _, _| Style::default());
285                draw_text_with_ellipsis_nowrap(progress_rect, buf, format!(" {} ", title), bold);
286            }
287        }
288    }
289}
290
291fn add_block_eta(state: progress::State, progress_text: &mut String) {
292    match state {
293        progress::State::Blocked(reason, maybe_eta) | progress::State::Halted(reason, maybe_eta) => {
294            progress_text.push_str(" [");
295            progress_text.push_str(reason);
296            progress_text.push(']');
297            if let Some(eta) = maybe_eta {
298                let eta = jiff::Timestamp::try_from(eta).expect("reasonable system time");
299                let now = jiff::Timestamp::now();
300                if eta > now {
301                    use std::fmt::Write;
302                    write!(
303                        progress_text,
304                        " → {:#} to {}",
305                        eta.duration_since(now),
306                        if let progress::State::Blocked(_, _) = state {
307                            "unblock"
308                        } else {
309                            "continue"
310                        }
311                    )
312                    .expect("in-memory writes never fail");
313                }
314            }
315        }
316        progress::State::Running => {}
317    }
318}
319
320fn draw_spinner(buf: &mut Buffer, bound: Rect, step: Step, seed: usize, color: Color) {
321    if bound.width == 0 {
322        return;
323    }
324    let x = bound.x + ((step + seed) % bound.width as usize) as u16;
325    let width = 5;
326    let bound = rect::intersect(Rect { x, width, ..bound }, bound);
327    tui_react::fill_background(bound, buf, color);
328}
329
330fn draw_progress_bar_fn(
331    buf: &mut Buffer,
332    bound: Rect,
333    fraction: f32,
334    style: impl FnOnce(f32) -> Color,
335) -> (Rect, Style) {
336    if bound.width == 0 {
337        return (Rect::default(), Style::default());
338    }
339    let mut fractional_progress_rect = Rect {
340        width: ((bound.width as f32 * fraction).floor() as u16).min(bound.width),
341        ..bound
342    };
343    let color = style(fraction);
344    for y in fractional_progress_rect.top()..fractional_progress_rect.bottom() {
345        for x in fractional_progress_rect.left()..fractional_progress_rect.right() {
346            let cell = buf.get_mut(x, y);
347            cell.set_fg(color);
348            cell.set_symbol(tui::symbols::block::FULL);
349        }
350    }
351    if fractional_progress_rect.width < bound.width {
352        static BLOCK_SECTIONS: [&str; 9] = [
353            " ",
354            tui::symbols::block::ONE_EIGHTH,
355            tui::symbols::block::ONE_QUARTER,
356            tui::symbols::block::THREE_EIGHTHS,
357            tui::symbols::block::HALF,
358            tui::symbols::block::FIVE_EIGHTHS,
359            tui::symbols::block::THREE_QUARTERS,
360            tui::symbols::block::SEVEN_EIGHTHS,
361            tui::symbols::block::FULL,
362        ];
363        // Get the index based on how filled the remaining part is
364        let index = ((((bound.width as f32 * fraction) - fractional_progress_rect.width as f32) * 8f32).round()
365            as usize)
366            % BLOCK_SECTIONS.len();
367        let cell = buf.get_mut(fractional_progress_rect.right(), bound.y);
368        cell.set_symbol(BLOCK_SECTIONS[index]);
369        cell.set_fg(color);
370        fractional_progress_rect.width += 1;
371    }
372    (fractional_progress_rect, Style::default().bg(color).fg(Color::Black))
373}
374
375pub fn draw_tree(entries: &[(Key, Task)], buf: &mut Buffer, bound: Rect, offset: u16) -> u16 {
376    let mut max_prefix_len = 0;
377    for (line, (entry_index, entry)) in entries
378        .iter()
379        .enumerate()
380        .skip(offset as usize)
381        .take(bound.height as usize)
382        .enumerate()
383    {
384        let mut line_bound = rect::line_bound(bound, line);
385        line_bound.x = line_bound.x.saturating_sub(1);
386        line_bound.width = line_bound.width.saturating_sub(1);
387        let tree_prefix = format!("{} {} ", level_prefix(entries, entry_index), entry.1.name);
388        max_prefix_len = max_prefix_len.max(block_width(&tree_prefix));
389
390        let style = if entry.1.progress.is_none() {
391            Style::default().add_modifier(Modifier::BOLD).into()
392        } else {
393            None
394        };
395        draw_text_with_ellipsis_nowrap(line_bound, buf, tree_prefix, style);
396    }
397    max_prefix_len
398}
399
400fn level_prefix(entries: &[(Key, Task)], entry_index: usize) -> String {
401    let adj = Key::adjacency(entries, entry_index);
402    let key = entries[entry_index].0;
403    let key_level = key.level();
404    let is_orphan = adj.level() != key_level;
405    let mut buf = String::with_capacity(key_level as usize);
406    for level in 1..=key_level {
407        use crate::progress::key::SiblingLocation::*;
408        let is_child_level = level == key_level;
409        if level != 1 {
410            buf.push(' ');
411        }
412        if level == 1 && is_child_level {
413            buf.push(match adj[level] {
414                AboveAndBelow | Above => '├',
415                NotFound | Below => '│',
416            });
417        } else {
418            let c = if is_child_level {
419                match adj[level] {
420                    NotFound => {
421                        if is_orphan {
422                            ' '
423                        } else {
424                            '·'
425                        }
426                    }
427                    Above => '└',
428                    Below => '┌',
429                    AboveAndBelow => '├',
430                }
431            } else {
432                match adj[level] {
433                    NotFound => {
434                        if level == 1 {
435                            '│'
436                        } else if is_orphan {
437                            '·'
438                        } else {
439                            ' '
440                        }
441                    }
442                    Above => '└',
443                    Below => '┌',
444                    AboveAndBelow => '│',
445                }
446            };
447            buf.push(c)
448        }
449    }
450    buf
451}
452
453pub fn draw_overflow(
454    entries: &[(Key, Task)],
455    buf: &mut Buffer,
456    bound: Rect,
457    label_offset: u16,
458    num_entries_on_display: u16,
459    offset: u16,
460) {
461    let (count, mut progress_fraction) = entries
462        .iter()
463        .take(offset as usize)
464        .chain(entries.iter().skip((offset + num_entries_on_display) as usize))
465        .fold((0usize, 0f32), |(count, progress_fraction), (_key, value)| {
466            let progress = value.progress.as_ref().and_then(|p| p.fraction()).unwrap_or_default();
467            (count + 1, progress_fraction + progress)
468        });
469    progress_fraction /= count as f32;
470    let label = format!(
471        "{} …{} skipped and {} more",
472        if label_offset == 0 { "" } else { VERTICAL_LINE },
473        offset,
474        entries
475            .len()
476            .saturating_sub((offset + num_entries_on_display + 1) as usize)
477    );
478    let (progress_rect, style) = draw_progress_bar_fn(buf, bound, progress_fraction, |_| Color::Green);
479
480    let bg_color = Color::Red;
481    fill_background(rect::offset_x(bound, progress_rect.right() - 1), buf, bg_color);
482    let color_text_according_to_progress = move |_g: &str, x: u16, _y: u16| {
483        if x < progress_rect.right() {
484            style
485        } else {
486            style.bg(bg_color)
487        }
488    };
489    draw_text_nowrap_fn(
490        rect::offset_x(bound, label_offset),
491        buf,
492        label,
493        color_text_according_to_progress,
494    );
495    let help_text = "⇊ = d|↓ = j|⇈ = u|↑ = k ";
496    draw_text_nowrap_fn(
497        rect::snap_to_right(bound, block_width(help_text)),
498        buf,
499        help_text,
500        color_text_according_to_progress,
501    );
502}