返回 CodeWhale
progress.rs
1 //! Progress from reported counts, never from an animation or elapsed time.
2 //!
3 //! [`CountBar`] says how many of how many in words (`3 of 5 phases`), because
4 //! the count is the fact. A bar beside it is optional ink and is drawn only
5 //! when the total is known:
6 //!
7 //! - no percentage, no time-remaining estimate, no smoothing: the bar fills
8 //! in whole cells, never reaches full before `done` reaches `total`, and
9 //! never shows a cell for work that has not happened;
10 //! - when `done` passes `total` the words keep the real numbers (`7 of 5`);
11 //! only the painted fill is clamped;
12 //! - when the total is unknown there is no bar at all, and the words say so
13 //! (`12 files so far, total unknown`) instead of guessing a fraction.
14 //!
15 //! The state (and so the mark and word) comes from the host; the bar never
16 //! infers "done" from the counts.
17
18 use std::borrow::Cow;
19
20 use ratatui::{
21 buffer::Buffer,
22 layout::Rect,
23 text::{Line, Span},
24 widgets::Widget,
25 };
26
27 use crate::{Paint, Role, State, StateWords, StatusMark, Theme, glyphs, text};
28
29 /// Words a count bar prints. `Default` is English.
30 #[derive(Clone, Debug, PartialEq, Eq)]
31 pub struct CountBarWords {
32 /// The word beside the mark.
33 pub states: StateWords,
34 /// `12 files so far, total unknown`: what follows the count.
35 pub unknown_total: Cow<'static, str>,
36 /// `3 of 5 phases`.
37 pub of: Cow<'static, str>,
38 /// `12 files so far`.
39 pub so_far: Cow<'static, str>,
40 }
41
42 impl Default for CountBarWords {
43 fn default() -> Self {
44 Self {
45 states: StateWords::default(),
46 unknown_total: Cow::Borrowed("total unknown"),
47 of: Cow::Borrowed("of"),
48 so_far: Cow::Borrowed("so far"),
49 }
50 }
51 }
52
53 /// `● Working 3 of 5 phases [██████░░░░░░]`.
54 ///
55 /// The host reports the state and the exact counts. Only the painted
56 /// fraction is clamped: `12 of 10` stays `12 of 10`. A zero total paints no
57 /// completed ink; an unknown total has no bar.
58 #[derive(Clone, Debug, PartialEq, Eq)]
59 pub struct CountBar {
60 pub done: u64,
61 /// `None` when nobody knows how many there will be.
62 pub total: Option<u64>,
63 pub state: State,
64 /// What is being counted: `phases`, `files`.
65 pub label: Cow<'static, str>,
66 pub words: CountBarWords,
67 }
68
69 impl CountBar {
70 /// `done` of `total`, working.
71 #[must_use]
72 pub fn new(done: u64, total: u64) -> Self {
73 Self {
74 done,
75 total: Some(total),
76 state: State::Working,
77 label: Cow::Borrowed(""),
78 words: CountBarWords::default(),
79 }
80 }
81
82 /// `done` so far, with no known total: the indeterminate form.
83 #[must_use]
84 pub fn unknown(done: u64) -> Self {
85 Self {
86 total: None,
87 state: State::Unknown,
88 ..Self::new(done, 0)
89 }
90 }
91
92 #[must_use]
93 pub fn state(mut self, state: State) -> Self {
94 self.state = state;
95 self
96 }
97
98 #[must_use]
99 pub fn label(mut self, label: impl Into<Cow<'static, str>>) -> Self {
100 self.label = label.into();
101 self
102 }
103
104 #[must_use]
105 pub fn with_words(mut self, words: &CountBarWords) -> Self {
106 self.words = words.clone();
107 self
108 }
109
110 /// How many bar cells are filled out of `cells`: whole cells, rounded
111 /// down, and never all of them before the work is complete.
112 #[must_use]
113 pub fn filled(&self, cells: u16) -> u16 {
114 let Some(total) = self.total else { return 0 };
115 if total == 0 || cells == 0 {
116 return 0;
117 }
118 if self.done >= total {
119 return cells;
120 }
121 let part = u128::from(self.done) * u128::from(cells) / u128::from(total);
122 u16::try_from(part).unwrap_or(cells).min(cells - 1)
123 }
124
125 /// The words: `3 of 5 phases`, or `12 files so far, total unknown`.
126 fn counting(&self) -> String {
127 let label = text::display_safe(&self.label);
128 let unit = if label.is_empty() {
129 String::new()
130 } else {
131 format!(" {label}")
132 };
133 match self.total {
134 Some(total) => format!(
135 "{} {} {total}{unit}",
136 self.done,
137 text::display_safe(&self.words.of)
138 ),
139 None => {
140 let sep = if unit.is_empty() { "" } else { " " };
141 format!(
142 "{}{unit}{sep}{}, {}",
143 self.done,
144 text::display_safe(&self.words.so_far),
145 text::display_safe(&self.words.unknown_total)
146 )
147 }
148 }
149 }
150 }
151
152 /// The bar never grows past this many cells; a longer one says nothing more.
153 const MAX_BAR: usize = 24;
154 /// Narrower than this the bar is left out: the words carry the count.
155 const MIN_BAR: usize = 6;
156
157 impl Paint for CountBar {
158 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
159 let area = area.intersection(buf.area);
160 if area.is_empty() {
161 return;
162 }
163 let width = usize::from(area.width);
164 let ascii = theme.ascii();
165 let mark = StatusMark::with_words(self.state, &self.words.states);
166 let glyph = mark.glyph(theme);
167 let word = text::display_safe(&mark.word).into_owned();
168 let counting = self.counting();
169 // When the row is too narrow the state word goes first (the mark
170 // stays), then the end of the count's words; never the numbers.
171 let with_word = text::width(glyph) + 1 + text::width(&word) + 2;
172 let without_word = text::width(glyph) + 1;
173 let counting_w = text::width(&counting);
174 let show_word = with_word + counting_w <= width;
175 let lead_w = if show_word { with_word } else { without_word };
176
177 let mut spans = vec![Span::styled(glyph, theme.fg(self.state.role()))];
178 if show_word {
179 spans.push(Span::styled(
180 format!(" {word} "),
181 theme.fg(Role::Foreground),
182 ));
183 } else {
184 spans.push(Span::raw(" "));
185 }
186 let room = width.saturating_sub(lead_w);
187 let fits = counting_w <= room;
188 let shown = if fits {
189 counting
190 } else {
191 text::truncate(&counting, room, ascii).into_owned()
192 };
193 spans.push(Span::styled(shown, theme.fg(Role::Foreground)));
194
195 if self.total.is_some() && fits {
196 let cells = (room - counting_w).saturating_sub(4).min(MAX_BAR);
197 if cells >= MIN_BAR {
198 let filled = usize::from(self.filled(cells as u16));
199 spans.push(Span::raw(" "));
200 spans.push(Span::styled("[", theme.fg(Role::Muted)));
201 spans.push(Span::styled(
202 glyphs::pick("\u{2588}", ascii).repeat(filled),
203 theme.fg(self.state.role()),
204 ));
205 spans.push(Span::styled(
206 glyphs::pick("\u{2591}", ascii).repeat(cells - filled),
207 theme.fg(Role::Dim),
208 ));
209 spans.push(Span::styled("]", theme.fg(Role::Muted)));
210 }
211 }
212 Line::from(spans).render(Rect::new(area.x, area.y, area.width, 1), buf);
213 }
214
215 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
216 1
217 }
218 }
219
219 lines RUST