on master help sign in
Add animated activity bars and evaluation progress (#8)
commit7c7e0b3da58e90bdb215a11670294ff2f7f5eea5 authoriko <mail@iko.soy> dateSun 11 Oct 2026, 02:24 (3 days ago) parentfda718b
5 files changed+218−29
README.md+6−3 specs/main.md+9−3 src/main.rs+12−10 src/terminal.rs+68−8 tests/terminal.rs+123−5
README.md+6−3
@@ -95,9 +95,12 @@ You'll want the reference `config.j` (see next section) and, for `review`/`difft
9595
9696In an interactive terminal, `j` uses vivid branch colors, a blue focus band,
9797colorful data and status messages, and animated progress indicators while
98−repository operations run. Colored trees and tables appear in a quick cascade.
99−Progress starts only when an operation takes a moment; fast commands do not
100−wait for a spinner.
98+repository operations or expressions run. Each indicator names the current
99+operation and shows a spinner, a moving activity bar, and elapsed time. The
100+bar shows activity, not a completion percentage; on narrow terminals it makes
101+room for the operation's name. Colored trees and tables appear in a quick
102+cascade. Progress starts only when an operation takes a moment; fast commands
103+do not wait for a spinner.
101104
102105To keep the colors but turn off animation:
103106
specs/main.md+9−3
@@ -216,10 +216,16 @@ values use stdout. Error prefixes are bright red, informational prefixes
216216bright cyan, and interactive successful commands have a green checkmark.
217217Color adds emphasis without replacing the text of errors or values.
218218
219−When both stdout and stderr are capable terminals, repository operations
219+When both stdout and stderr are capable terminals, repository operations,
220+configuration evaluation, and expression evaluation (including implicit application)
220221may show a temporary, color-cycling progress line on stderr. It starts after
221−140 ms, advances every 75 ms, and is cleared before the command reports its
222−result. Fast operations never wait for a progress frame. It does not hide
222+140 ms, advances every 75 ms, and names the current operation alongside a
223+spinner, a bouncing activity bar, and elapsed time since that operation began.
224+The bar is indeterminate and does not estimate completion. Each frame fits
225+the current stderr terminal width with a spare column, omitting the bar and
226+then the time before truncating the operation's name by display width. The
227+line is cleared before the command reports its result.
228+Fast operations never wait for a progress frame. It does not hide
223229the cursor or change terminal modes. Signal behavior remains as in §1.3.
224230Before an external renderer inherits stderr, progress is cleared and stopped
225231for the remainder of the run so even an unterminated diagnostic stays visible.
src/main.rs+12−10
@@ -476,26 +476,28 @@ fn run_expression(text: &str, snapshot: bool) -> ExitCode {
476476 };
477477
478478 // §1.2.6: evaluate config definitions, then the expression
479− if let Err(c) = config::eval_config(&mut interp, &cfg) {
479+ if let Err(c) = j::terminal::with_progress("Evaluating configuration", || {
480+ config::eval_config(&mut interp, &cfg)
481+ }) {
480482 return err(3, format!("config.j: {}", c.msg));
481483 }
482484 *interp.old_repo.borrow_mut() = Some(loaded_repo.clone());
483485 *interp.given_repo.borrow_mut() = Some(current_repo.clone());
484486 let env = interp.global_env();
485487 let expr_rc = Rc::new(expr);
486− let mut v = match interp.eval(&expr_rc, &env) {
488+ let v = match j::terminal::with_progress("Evaluating expression", || {
489+ let v = interp.eval(&expr_rc, &env)?;
490+ // §1.2.7: apply a function result to the repo, once.
491+ if matches!(v, Value::Fun(_)) {
492+ interp.apply(v, current_repo.clone())
493+ } else {
494+ Ok(v)
495+ }
496+ }) {
487497 Ok(v) => v,
488498 Err(c) => return crash_err(&c, Some(text)),
489499 };
490500
491− // §1.2.7: apply a function result to the repo, once
492− if matches!(v, Value::Fun(_)) {
493− v = match interp.apply(v, current_repo.clone()) {
494− Ok(v) => v,
495− Err(c) => return crash_err(&c, Some(text)),
496− };
497− }
498−
499501 // §1.2.8: a Repo is persisted (unless unchanged)
500502 if is_repo_value(&v) {
501503 match j::terminal::with_progress("Saving changes", || {
src/terminal.rs+68−8
@@ -7,7 +7,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
77use std::sync::mpsc::{self, Sender};
88use std::sync::{Arc, Mutex, Weak};
99use std::thread::{self, JoinHandle};
10−use std::time::Duration;
10+use std::time::{Duration, Instant};
11+
12+use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1113
1214static ACTIVE_PROGRESS: Mutex<Option<Weak<ProgressWorker>>> = Mutex::new(None);
1315static EXTERNAL_STDERR: AtomicBool = AtomicBool::new(false);
@@ -88,8 +90,10 @@ pub fn success(message: &str) {
8890 }
8991}
9092
91−/// A delayed, color-cycling spinner. Stopping wakes the worker immediately,
92−/// so short operations have no extra delay. Never hide or move the cursor
93+/// A delayed spinner, activity bar, and elapsed time. The bar is deliberately
94+/// indeterminate: elapsed time cannot tell us how much work remains.
95+/// Stopping wakes the worker immediately, so short operations have no extra
96+/// delay. Never hide or move the cursor
9397/// between rows: even an abrupt signal leaves the terminal usable.
9498struct Progress {
9599 worker: Option<Arc<ProgressWorker>>,
@@ -116,18 +120,18 @@ impl Progress {
116120 }
117121 stop_progress();
118122 let (send, receive) = mpsc::channel();
119− // Leave a spare column so the status line cannot wrap at the edge.
120− let width = stderr_width().saturating_sub(1);
123+ let started = Instant::now();
121124 let worker = thread::Builder::new().name("j-progress".into()).spawn(move || {
122125 if receive.recv_timeout(Duration::from_millis(140)) != Err(mpsc::RecvTimeoutError::Timeout) {
123126 return;
124127 }
125− let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
126128 let colors = ["95", "96", "93", "92", "94"];
127129 let mut frame = 0;
128130 loop {
129− let line = format!("{} j · {}", frames[frame % frames.len()], label);
130− let line: String = line.chars().take(width).collect();
131+ // Fit each new frame to the current terminal width, including
132+ // after a resize. Leave one column spare at the edge.
133+ let width = stderr_width().saturating_sub(1);
134+ let line = progress_line(label, frame, started.elapsed(), width);
131135 {
132136 let mut out = io::stderr().lock();
133137 if write!(
@@ -173,6 +177,39 @@ pub fn with_progress<T>(label: &'static str, operation: impl FnOnce() -> T) -> T
173177 operation()
174178}
175179
180+fn progress_line(label: &str, frame: usize, elapsed: Duration, width: usize) -> String {
181+ const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
182+ let mut line = format!("{} j · {}", FRAMES[frame % FRAMES.len()], label);
183+ let seconds = elapsed.as_secs();
184+ let time = if seconds < 60 {
185+ format!("{}.{}s", seconds, elapsed.subsec_millis() / 100)
186+ } else {
187+ format!("{}m {:02}s", seconds / 60, seconds % 60)
188+ };
189+ // Bounce a three-cell pulse across a ten-cell track. It never fills up
190+ // or suggests a completion percentage for work with no known total.
191+ let step = frame % 14;
192+ let start = step.min(14 - step);
193+ let bar: String = (0..10)
194+ .map(|cell| if (start..start + 3).contains(&cell) { '━' } else { '·' })
195+ .collect();
196+ let details = format!(" [{bar}] {time}");
197+ if line.width() + details.width() <= width {
198+ line.push_str(&details);
199+ } else if line.width() + 1 + time.width() <= width {
200+ // On small terminals, keep the operation's name ahead of decoration.
201+ line.push(' ');
202+ line.push_str(&time);
203+ }
204+ let mut used = 0;
205+ line.chars()
206+ .take_while(|c| {
207+ used += c.width().unwrap_or(0);
208+ used <= width
209+ })
210+ .collect()
211+}
212+
176213fn stderr_width() -> usize {
177214 // The two streams can be attached to different terminals. Measure the
178215 // one that receives the progress line, rather than the tree's stdout.
@@ -212,3 +249,26 @@ pub fn write_output(out: &mut impl Write, text: &str) -> io::Result<()> {
212249 out.write_all(text[start..].as_bytes())?;
213250 out.flush()
214251}
252+
253+#[cfg(test)]
254+mod tests {
255+ use super::*;
256+
257+ #[test]
258+ fn progress_fits_terminal_cells_and_keeps_the_operation_visible() {
259+ let label = "Reading 仓库";
260+ for width in 0..80 {
261+ for frame in 0..20 {
262+ let line = progress_line(label, frame, Duration::from_millis(1234), width);
263+ assert!(line.width() <= width, "{width} columns: {line:?}");
264+ if width >= 18 {
265+ assert!(line.contains(label), "operation name lost: {line:?}");
266+ }
267+ }
268+ }
269+ let line = progress_line("Reading repository", 0, Duration::from_millis(1234), 80);
270+ assert!(line.contains("[━━━·······] 1.2s"), "{line:?}");
271+ assert!(progress_line("Reading repository", 0, Duration::from_secs(65), 80)
272+ .ends_with("1m 05s"));
273+ }
274+}
tests/terminal.rs+123−5
@@ -9,6 +9,12 @@ use std::os::fd::{FromRawFd, RawFd};
99use std::os::unix::fs::PermissionsExt;
1010use std::path::PathBuf;
1111use std::process::{Command, Stdio};
12+use unicode_width::UnicodeWidthStr;
13+
14+const CLEAR_LINE: &str = "\r\x1b[2K";
15+// A bounded builtin fold keeps evaluation active for multiple progress frames
16+// without constructing a deep recursive value or touching the network.
17+const SLOW_SUM: &str = "foldl (+) 0 (range 0 500000)";
1218
1319struct Fixture {
1420 root: tempfile::TempDir,
@@ -84,8 +90,14 @@ struct Out {
8490
8591/// Return the master to capture output and the slave to install as stdout or
8692/// stderr. Disable newline translation so assertions see the bytes j wrote.
87−fn pty() -> (File, File) {
93+fn pty(columns: u16) -> (File, File) {
8894 let (mut master, mut slave): (RawFd, RawFd) = (-1, -1);
95+ let size = libc::winsize {
96+ ws_row: 24,
97+ ws_col: columns,
98+ ws_xpixel: 0,
99+ ws_ypixel: 0,
100+ };
89101 assert_eq!(
90102 unsafe {
91103 libc::openpty(
@@ -93,7 +105,7 @@ fn pty() -> (File, File) {
93105 &mut slave,
94106 std::ptr::null_mut(),
95107 std::ptr::null(),
96− std::ptr::null(),
108+ &size,
97109 )
98110 },
99111 0,
@@ -128,12 +140,21 @@ fn read_stream(mut reader: Box<dyn Read + Send>) -> String {
128140 String::from_utf8(bytes).unwrap()
129141}
130142
131−fn capture(mut command: Command, stdout_tty: bool, stderr_tty: bool) -> Out {
132− let stdout = stdout_tty.then(pty).map(|(master, slave)| {
143+fn capture(command: Command, stdout_tty: bool, stderr_tty: bool) -> Out {
144+ capture_with_stderr_width(command, stdout_tty, stderr_tty, 80)
145+}
146+
147+fn capture_with_stderr_width(
148+ mut command: Command,
149+ stdout_tty: bool,
150+ stderr_tty: bool,
151+ columns: u16,
152+) -> Out {
153+ let stdout = stdout_tty.then(|| pty(80)).map(|(master, slave)| {
133154 command.stdout(slave);
134155 master
135156 });
136− let stderr = stderr_tty.then(pty).map(|(master, slave)| {
157+ let stderr = stderr_tty.then(|| pty(columns)).map(|(master, slave)| {
137158 command.stderr(slave);
138159 master
139160 });
@@ -201,6 +222,26 @@ fn without_sgr(text: &str) -> String {
201222 plain
202223}
203224
225+fn assert_progress(out: &Out, label: &str) {
226+ let frames: Vec<_> = out.stderr.split(CLEAR_LINE)
227+ .filter(|frame| frame.contains(label))
228+ .map(without_sgr)
229+ .collect();
230+ assert!(frames.len() >= 2, "no repeated {label} frames: {:?}", out.stderr);
231+ let mut bars = std::collections::BTreeSet::new();
232+ for frame in &frames {
233+ let (_, details) = frame.split_once(" [")
234+ .unwrap_or_else(|| panic!("missing activity bar: {frame:?}"));
235+ let (bar, elapsed) = details.split_once("] ")
236+ .unwrap_or_else(|| panic!("missing elapsed time: {frame:?}"));
237+ assert!(bar.contains('━') && bar.contains('·'), "invalid activity bar: {bar:?}");
238+ assert!(elapsed.strip_suffix('s').and_then(|s| s.parse::<f64>().ok()).is_some(),
239+ "invalid elapsed time: {elapsed:?}");
240+ bars.insert(bar);
241+ }
242+ assert!(bars.len() >= 2, "activity bar did not animate: {frames:?}");
243+}
244+
204245#[test]
205246fn terminal_tree_is_colored_and_raw_text_is_unchanged() {
206247 let fixture = Fixture::new();
@@ -218,6 +259,65 @@ fn terminal_tree_is_colored_and_raw_text_is_unchanged() {
218259 assert_eq!(terminal.stdout, "a rainbow\nkeeps its bytes\n");
219260}
220261
262+#[test]
263+fn slow_expression_animates_and_clears_before_reporting_failure() {
264+ let fixture = Fixture::new();
265+ fixture.init();
266+ let expr = format!(
267+ "if {SLOW_SUM} == 124999750000 then crash \"expression fixture failure\" else \"\""
268+ );
269+ let out = capture(fixture.command(&[&expr]), true, true);
270+ assert_eq!(out.code, 1, "{}", out.stderr);
271+ assert!(out.stdout.is_empty(), "progress leaked into stdout: {:?}", out.stdout);
272+ assert_progress(&out, "Evaluating expression");
273+ let last_clear = out.stderr.rfind(CLEAR_LINE).expect("progress was never cleared");
274+ let diagnostic = without_sgr(&out.stderr[last_clear + CLEAR_LINE.len()..]);
275+ assert!(diagnostic.starts_with("j:"), "progress label was not cleared: {diagnostic:?}");
276+ assert!(diagnostic.contains("expression fixture failure"), "{diagnostic:?}");
277+ assert!(diagnostic.ends_with('\n'), "diagnostic did not end a line: {diagnostic:?}");
278+}
279+
280+#[test]
281+fn slow_implicit_application_preserves_output_and_respects_opt_outs() {
282+ let fixture = Fixture::new();
283+ fixture.init();
284+ // Evaluating this lambda is fast; applying it to the implicit repository
285+ // must remain inside the same progress phase.
286+ let expr = format!(
287+ "\\repo -> if {SLOW_SUM} == 124999750000 then \"computed\\nunchanged\\n\" else crash \"unexpected sum\""
288+ );
289+ let animated = capture(fixture.command(&[&expr]), true, true);
290+ assert_eq!(animated.code, 0, "{}", animated.stderr);
291+ assert_eq!(animated.stdout, "computed\nunchanged\n");
292+ assert_progress(&animated, "Evaluating expression");
293+ assert!(animated.stderr.ends_with(CLEAR_LINE), "progress was not cleared: {:?}", animated.stderr);
294+
295+ let mut command = fixture.command(&[&expr]);
296+ command.env("J_ANIMATIONS", "off");
297+ let opted_out = capture(command, true, true);
298+ let piped = capture(fixture.command(&[&expr]), false, false);
299+ for out in [opted_out, piped] {
300+ assert_eq!(out.code, 0, "{}", out.stderr);
301+ assert_eq!(out.stdout, animated.stdout);
302+ assert_no_motion(&out);
303+ }
304+}
305+
306+#[test]
307+fn slow_configuration_animates_and_clears_before_output() {
308+ let fixture = Fixture::new();
309+ fixture.init();
310+ let path = fixture.cfg.join("j/config.j");
311+ let mut config = std::fs::read_to_string(&path).unwrap();
312+ config.push_str(&format!("\nterminalWork = {SLOW_SUM}\n"));
313+ std::fs::write(path, config).unwrap();
314+ let out = capture(fixture.command(&["show terminalWork"]), true, true);
315+ assert_eq!(out.code, 0, "{}", out.stderr);
316+ assert_eq!(out.stdout, "124999750000\n");
317+ assert_progress(&out, "Evaluating configuration");
318+ assert!(out.stderr.ends_with(CLEAR_LINE), "progress was not cleared: {:?}", out.stderr);
319+}
320+
221321#[test]
222322fn slow_work_animates_and_clears_before_reporting_failure() {
223323 let fixture = Fixture::new();
@@ -253,6 +353,24 @@ fn successful_slow_work_clears_before_its_confirmation() {
253353 assert!(fixture.dir.join("clone/.jj").is_dir());
254354}
255355
356+#[test]
357+fn progress_fits_a_narrow_stderr_terminal() {
358+ let fixture = Fixture::new();
359+ // stdout stays 80 columns wide: progress must measure its own stderr.
360+ let out = capture_with_stderr_width(fixture.slow_clone(false), true, true, 18);
361+ assert_eq!(out.code, 0, "{}", out.stderr);
362+ let segments: Vec<_> = out.stderr.split(CLEAR_LINE).skip(1).collect();
363+ let (confirmation, frames) = segments.split_last().expect("progress was never cleared");
364+ assert!(frames.len() >= 2, "no repeated frames: {:?}", out.stderr);
365+ for frame in frames {
366+ let plain = without_sgr(frame);
367+ assert!(!plain.contains('\n'), "progress used multiple rows: {plain:?}");
368+ assert!(plain.width() <= 17, "progress wrapped the 18-column terminal: {plain:?}");
369+ }
370+ assert!(without_sgr(confirmation).starts_with('✓'), "confirmation was not preserved: {confirmation:?}");
371+ assert!(out.stdout.is_empty(), "progress leaked into stdout: {:?}", out.stdout);
372+}
373+
256374#[test]
257375fn inherited_difft_diagnostic_is_not_erased_by_progress() {
258376 let fixture = Fixture::new();