Skip to content

Commit 02902ae

Browse files
committed
Clear one line at a time so there's less flickering
Move ANSI-heavy tests into unit tests so that they are more readable.
1 parent d4e593a commit 02902ae

File tree

5 files changed

+321
-206
lines changed

5 files changed

+321
-206
lines changed

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
- Change back to `std::sync::Mutex` from `parking_lot`, to keep dependencies smaller.
1010

11+
- When painting multi-line progress, clear to the end of each line as it's painted, rather than previously clearing to the end of the screen. This reduces flickering on large multi-line progress bars.
12+
1113
## 0.1.4
1214

1315
Released 2023-09-23

src/ansi.rs

Lines changed: 310 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@
55
// References:
66
// * <https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797>
77

8-
#![allow(unused)]
9-
108
use std::borrow::Cow;
119

10+
#[allow(dead_code)]
11+
pub(crate) const ESC: &str = "\x1b";
12+
1213
pub(crate) const MOVE_TO_START_OF_LINE: &str = "\x1b[1G";
1314

1415
// https://vt100.net/docs/vt510-rm/DECAWM
1516
pub(crate) const DISABLE_LINE_WRAP: &str = "\x1b[?7l";
1617
pub(crate) const ENABLE_LINE_WRAP: &str = "\x1b[?7h";
1718

1819
pub(crate) const CLEAR_TO_END_OF_LINE: &str = "\x1b[0K";
20+
#[allow(dead_code)]
1921
pub(crate) const CLEAR_CURRENT_LINE: &str = "\x1b[2K";
2022
pub(crate) const CLEAR_TO_END_OF_SCREEN: &str = "\x1b[0J";
2123

@@ -37,14 +39,313 @@ pub(crate) fn enable_windows_ansi() -> bool {
3739
true
3840
}
3941

40-
pub(crate) fn insert_codes(rendered: &str, up_lines: Option<usize>) -> String {
42+
pub(crate) fn insert_codes(rendered: &str, cursor_y: Option<usize>) -> (String, usize) {
4143
let mut buf = String::with_capacity(rendered.len() + 40);
42-
if let Some(up_lines) = up_lines {
43-
buf.push_str(&up_n_lines_and_home(up_lines));
44-
}
44+
buf.push_str(&up_n_lines_and_home(cursor_y.unwrap_or_default()));
4545
buf.push_str(DISABLE_LINE_WRAP);
46-
buf.push_str(CLEAR_TO_END_OF_SCREEN);
47-
buf.push_str(rendered);
46+
// buf.push_str(CLEAR_TO_END_OF_SCREEN);
47+
let mut first = true;
48+
let mut n_lines = 0;
49+
for line in rendered.lines() {
50+
if !first {
51+
buf.push('\n');
52+
n_lines += 1;
53+
} else {
54+
first = false;
55+
}
56+
buf.push_str(line);
57+
buf.push_str(CLEAR_TO_END_OF_LINE);
58+
}
4859
buf.push_str(ENABLE_LINE_WRAP);
49-
buf
60+
(buf, n_lines)
61+
}
62+
63+
#[cfg(test)]
64+
mod test {
65+
use std::{
66+
mem::take,
67+
ops::DerefMut,
68+
thread::sleep,
69+
time::{Duration, Instant},
70+
};
71+
72+
use super::*;
73+
use crate::{Destination, Model, Options, View};
74+
75+
struct MultiLineModel {
76+
i: usize,
77+
}
78+
79+
impl Model for MultiLineModel {
80+
fn render(&mut self, _width: usize) -> String {
81+
format!(" count: {}\n bar: {}\n", self.i, "*".repeat(self.i),)
82+
}
83+
}
84+
85+
#[test]
86+
fn draw_progress_once() {
87+
let model = MultiLineModel { i: 0 };
88+
let options = Options::default().destination(Destination::Capture);
89+
let view = View::new(model, options);
90+
let output = view.captured_output();
91+
92+
view.update(|model| model.i = 1);
93+
94+
let written = output.lock().unwrap().to_owned();
95+
assert_eq!(
96+
written,
97+
MOVE_TO_START_OF_LINE.to_string()
98+
+ DISABLE_LINE_WRAP
99+
+ " count: 1"
100+
+ CLEAR_TO_END_OF_LINE
101+
+ "\n"
102+
+ " bar: *"
103+
+ CLEAR_TO_END_OF_LINE
104+
+ ENABLE_LINE_WRAP
105+
);
106+
output.lock().unwrap().clear();
107+
108+
drop(view);
109+
let written = output.lock().unwrap().to_owned();
110+
assert_eq!(
111+
written,
112+
ESC.to_owned() + "[1F" + CLEAR_TO_END_OF_SCREEN + ENABLE_LINE_WRAP
113+
)
114+
}
115+
116+
#[test]
117+
fn abandoned_bar_is_not_erased() {
118+
let model = MultiLineModel { i: 0 };
119+
let view = View::new(model, Options::default().destination(Destination::Capture));
120+
let output = view.captured_output();
121+
122+
view.update(|model| model.i = 1);
123+
view.abandon();
124+
125+
// No erasure commands, just a newline after the last painted view.
126+
let written = output.lock().unwrap().to_owned();
127+
assert_eq!(
128+
written,
129+
MOVE_TO_START_OF_LINE.to_owned()
130+
+ DISABLE_LINE_WRAP
131+
+ " count: 1"
132+
+ CLEAR_TO_END_OF_LINE
133+
+ "\n"
134+
+ " bar: *"
135+
+ CLEAR_TO_END_OF_LINE
136+
+ ENABLE_LINE_WRAP
137+
+ "\n"
138+
);
139+
}
140+
141+
#[test]
142+
fn rate_limiting_with_fake_clock() {
143+
struct Model {
144+
draw_count: usize,
145+
update_count: usize,
146+
}
147+
impl crate::Model for Model {
148+
fn render(&mut self, _width: usize) -> String {
149+
self.draw_count += 1;
150+
format!("update:{} draw:{}", self.update_count, self.draw_count)
151+
}
152+
}
153+
let model = Model {
154+
draw_count: 0,
155+
update_count: 0,
156+
};
157+
let options = Options::default()
158+
.destination(Destination::Capture)
159+
.fake_clock(true)
160+
.update_interval(Duration::from_millis(1));
161+
let mut fake_clock = Instant::now();
162+
let view = View::new(model, options);
163+
view.set_fake_clock(fake_clock);
164+
let output = view.captured_output();
165+
166+
// Any number of updates, but until the clock ticks only one will be drawn.
167+
for _i in 0..10 {
168+
view.update(|model| model.update_count += 1);
169+
sleep(Duration::from_millis(10));
170+
}
171+
assert_eq!(view.inspect_model(|m| m.draw_count), 1);
172+
assert_eq!(view.inspect_model(|m| m.update_count), 10);
173+
174+
// Time passes...
175+
fake_clock += Duration::from_secs(1);
176+
view.set_fake_clock(fake_clock);
177+
// Another burst of updates, and just one of them will be drawn.
178+
for _i in 0..10 {
179+
view.update(|model| model.update_count += 1);
180+
sleep(Duration::from_millis(10));
181+
}
182+
assert_eq!(view.inspect_model(|m| m.draw_count), 2);
183+
assert_eq!(view.inspect_model(|m| m.update_count), 20);
184+
185+
drop(view);
186+
let written = output.lock().unwrap().to_owned();
187+
assert_eq!(
188+
written,
189+
MOVE_TO_START_OF_LINE.to_owned()
190+
+ DISABLE_LINE_WRAP
191+
+ "update:1 draw:1"
192+
+ CLEAR_TO_END_OF_LINE
193+
+ ENABLE_LINE_WRAP
194+
+ MOVE_TO_START_OF_LINE
195+
+ DISABLE_LINE_WRAP
196+
+ "update:11 draw:2"
197+
+ CLEAR_TO_END_OF_LINE
198+
+ ENABLE_LINE_WRAP
199+
+ MOVE_TO_START_OF_LINE
200+
+ CLEAR_TO_END_OF_SCREEN
201+
+ ENABLE_LINE_WRAP
202+
);
203+
}
204+
205+
/// If output is redirected, it should not be affected by the width of
206+
/// wherever stdout is pointing.
207+
#[test]
208+
fn default_width_when_not_on_stdout() {
209+
struct Model();
210+
impl crate::Model for Model {
211+
fn render(&mut self, width: usize) -> String {
212+
assert_eq!(width, 80);
213+
format!("width={width}")
214+
}
215+
}
216+
let model = Model();
217+
let options = Options::default().destination(Destination::Capture);
218+
let view = View::new(model, options);
219+
let output = view.captured_output();
220+
221+
view.update(|_model| ());
222+
let written = output.lock().unwrap().to_owned();
223+
assert_eq!(
224+
written,
225+
MOVE_TO_START_OF_LINE.to_owned()
226+
+ DISABLE_LINE_WRAP
227+
+ "width=80"
228+
+ CLEAR_TO_END_OF_LINE
229+
+ ENABLE_LINE_WRAP
230+
);
231+
232+
drop(view);
233+
}
234+
235+
#[test]
236+
fn suspend_and_resume() {
237+
struct Model(usize);
238+
impl crate::Model for Model {
239+
fn render(&mut self, _width: usize) -> String {
240+
format!("XX: {}", self.0)
241+
}
242+
}
243+
let model = Model(0);
244+
let options = Options::default()
245+
.destination(Destination::Capture)
246+
.update_interval(Duration::ZERO);
247+
let view = View::new(model, options);
248+
let output = view.captured_output();
249+
250+
// Paint 0 before it's suspended
251+
view.update(|model| model.0 = 0);
252+
let written = take(output.lock().unwrap().deref_mut());
253+
assert_eq!(
254+
written,
255+
MOVE_TO_START_OF_LINE.to_owned()
256+
+ DISABLE_LINE_WRAP
257+
+ "XX: 0"
258+
+ CLEAR_TO_END_OF_LINE
259+
+ ENABLE_LINE_WRAP
260+
);
261+
262+
// Now suspend; this clears the bar from the screen.
263+
view.suspend();
264+
view.update(|model| model.0 = 1);
265+
let written = take(output.lock().unwrap().deref_mut());
266+
assert_eq!(
267+
written,
268+
MOVE_TO_START_OF_LINE.to_owned() + CLEAR_TO_END_OF_SCREEN + ENABLE_LINE_WRAP
269+
);
270+
271+
// * 2 is also updated into the model while the bar is suspended, but then
272+
// it's resumed, so 2 is then painted.
273+
view.update(|model| model.0 = 2);
274+
let written = take(output.lock().unwrap().deref_mut());
275+
assert_eq!(written, "");
276+
277+
// Now 2 is painted when resumed.
278+
view.resume();
279+
let written = take(output.lock().unwrap().deref_mut());
280+
assert_eq!(
281+
written,
282+
MOVE_TO_START_OF_LINE.to_owned()
283+
+ DISABLE_LINE_WRAP
284+
+ "XX: 2"
285+
+ CLEAR_TO_END_OF_LINE
286+
+ ENABLE_LINE_WRAP
287+
);
288+
289+
// * 3 and 4 are painted in the usual way.
290+
view.update(|model| model.0 = 3);
291+
view.update(|model| model.0 = 4);
292+
let written = take(output.lock().unwrap().deref_mut());
293+
assert_eq!(
294+
written,
295+
MOVE_TO_START_OF_LINE.to_owned()
296+
+ DISABLE_LINE_WRAP
297+
+ "XX: 3"
298+
+ CLEAR_TO_END_OF_LINE
299+
+ ENABLE_LINE_WRAP
300+
+ MOVE_TO_START_OF_LINE
301+
+ DISABLE_LINE_WRAP
302+
+ "XX: 4"
303+
+ CLEAR_TO_END_OF_LINE
304+
+ ENABLE_LINE_WRAP
305+
);
306+
307+
view.abandon();
308+
let written = take(output.lock().unwrap().deref_mut());
309+
assert_eq!(written, "\n");
310+
}
311+
312+
#[test]
313+
fn identical_output_suppressed() {
314+
struct Hundreds(usize);
315+
316+
impl Model for Hundreds {
317+
fn render(&mut self, _width: usize) -> String {
318+
format!("hundreds={}", self.0 / 100)
319+
}
320+
}
321+
322+
let options = Options::default()
323+
.destination(Destination::Capture)
324+
.update_interval(Duration::ZERO);
325+
let view = View::new(Hundreds(0), options);
326+
let output = view.captured_output();
327+
328+
for i in 0..200 {
329+
// We change the model, but not in a way that will change what's displayed.
330+
view.update(|model| model.0 = i);
331+
}
332+
view.abandon();
333+
334+
// No erasure commands, just a newline after the last painted view.
335+
let written = output.lock().unwrap().to_owned();
336+
assert_eq!(
337+
written,
338+
MOVE_TO_START_OF_LINE.to_owned()
339+
+ DISABLE_LINE_WRAP
340+
+ "hundreds=0"
341+
+ CLEAR_TO_END_OF_LINE
342+
+ ENABLE_LINE_WRAP
343+
+ MOVE_TO_START_OF_LINE
344+
+ DISABLE_LINE_WRAP
345+
+ "hundreds=1"
346+
+ CLEAR_TO_END_OF_LINE
347+
+ ENABLE_LINE_WRAP
348+
+ "\n" // bar abandoned
349+
);
350+
}
50351
}

src/lib.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -747,16 +747,18 @@ impl<M: Model> InnerView<M> {
747747
// be simpler?)
748748
rendered.pop();
749749
}
750-
let up_lines = match self.state {
750+
let cursor_y = match self.state {
751751
State::ProgressDrawn {
752752
ref last_drawn_string,
753-
cursor_y,
754753
..
755-
} if *last_drawn_string != rendered => Some(cursor_y),
754+
} if *last_drawn_string == rendered => {
755+
return Ok(());
756+
}
757+
State::ProgressDrawn { cursor_y, .. } => Some(cursor_y),
756758
_ => None,
757759
};
758-
self.write_output(&insert_codes(&rendered, up_lines));
759-
let cursor_y = rendered.as_bytes().iter().filter(|b| **b == b'\n').count();
760+
let (buf, cursor_y) = insert_codes(&rendered, cursor_y);
761+
self.write_output(&buf);
760762
self.state = State::ProgressDrawn {
761763
last_drawn_time: now,
762764
last_drawn_string: rendered,

0 commit comments

Comments
 (0)