1//! Interactive presentation. Rendered values stay free of cursor controls;
2//! motion belongs only to the CLI's final output and temporary status line.
5use std::io::{self, IsTerminal, Write};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::mpsc::{self, Sender};
8use std::sync::{Arc, Mutex, Weak};
9use std::thread::{self, JoinHandle};
10use std::time::{Duration, Instant};
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14static ACTIVE_PROGRESS: Mutex<Option<Weak<ProgressWorker>>> = Mutex::new(None);
15static EXTERNAL_STDERR: AtomicBool = AtomicBool::new(false);
18 let worker = ACTIVE_PROGRESS.lock().unwrap().as_ref().and_then(Weak::upgrade);
19 if let Some(worker) = worker {
24/// A subprocess may write an unterminated diagnostic. Clear our status before
25/// it starts, and leave stderr alone for the remainder of this run.
26pub fn external_stderr() {
27 EXTERNAL_STDERR.store(true, Ordering::Relaxed);
31fn nonempty_env(name: &str) -> bool {
32 std::env::var_os(name).is_some_and(|value| !value.is_empty())
35fn capable_terminal() -> bool {
36 !nonempty_env("NO_COLOR") && std::env::var("TERM").as_deref() != Ok("dumb")
39fn interactive() -> bool {
40 io::stdout().is_terminal()
41 && io::stderr().is_terminal()
42 && !nonempty_env("CI")
45fn motion_enabled() -> bool {
49 std::env::var("J_ANIMATIONS")
53 "never" | "off" | "false" | "0"
57/// Write a diagnostic without turning a closed stderr into a panic (§1.4).
58pub fn report(args: fmt::Arguments<'_>) {
59 report_with_color(args, "1;96");
62pub fn report_error(args: fmt::Arguments<'_>) {
63 report_with_color(args, "1;91");
66fn report_with_color(args: fmt::Arguments<'_>, code: &str) {
68 let mut out = io::stderr().lock();
69 if out.is_terminal() && capable_terminal() {
70 let message = args.to_string();
71 if let Some(rest) = message.strip_prefix("j: ") {
72 let _ = writeln!(out, "\x1b[{code}mj:\x1b[0m {rest}");
74 let _ = writeln!(out, "\x1b[2m{message}\x1b[0m");
77 let _ = writeln!(out, "{args}");
81/// Short confirmation for commands that otherwise have no terminal output.
82/// Pipes and unattended runs keep their existing, quiet behavior.
83pub fn success(message: &str) {
86 let mut out = io::stderr().lock();
87 if capable_terminal() {
88 let _ = writeln!(out, "\x1b[1;92m✓\x1b[0m \x1b[1;96mj:\x1b[0m {message}");
90 let _ = writeln!(out, "j: {message}");
95/// A delayed spinner, activity bar, and elapsed time. The bar is deliberately
96/// indeterminate: elapsed time cannot tell us how much work remains.
97/// Stopping wakes the worker immediately, so short operations have no extra
98/// delay. Never hide or move the cursor
99/// between rows: even an abrupt signal leaves the terminal usable.
101 worker: Option<Arc<ProgressWorker>>,
104struct ProgressWorker {
106 thread: Mutex<Option<JoinHandle<()>>>,
111 let _ = self.stop.send(());
112 if let Some(worker) = self.thread.lock().unwrap().take() {
113 let _ = worker.join();
119 fn start(label: &'static str) -> Self {
120 if !motion_enabled() || EXTERNAL_STDERR.load(Ordering::Relaxed) {
121 return Self { worker: None };
124 let (send, receive) = mpsc::channel();
125 let started = Instant::now();
126 let worker = thread::Builder::new().name("j-progress".into()).spawn(move || {
127 if receive.recv_timeout(Duration::from_millis(140)) != Err(mpsc::RecvTimeoutError::Timeout) {
130 let colors = ["95", "96", "93", "92", "94"];
133 // Fit each new frame to the current terminal width, including
134 // after a resize. Leave one column spare at the edge.
135 let width = stderr_width().saturating_sub(1);
136 let line = progress_line(label, frame, started.elapsed(), width);
138 let mut out = io::stderr().lock();
141 "\r\x1b[2K\x1b[1;{}m{}\x1b[0m",
142 colors[frame % colors.len()],
145 .and_then(|()| out.flush())
152 if receive.recv_timeout(Duration::from_millis(75)) != Err(mpsc::RecvTimeoutError::Timeout) {
156 let mut out = io::stderr().lock();
157 let _ = write!(out, "\r\x1b[2K").and_then(|()| out.flush());
159 let worker = worker.ok().map(|worker| Arc::new(ProgressWorker {
161 thread: Mutex::new(Some(worker)),
163 *ACTIVE_PROGRESS.lock().unwrap() = worker.as_ref().map(Arc::downgrade);
168impl Drop for Progress {
170 if let Some(worker) = self.worker.take() {
176/// Finish the temporary line before the caller reports success or failure.
177pub fn with_progress<T>(label: &'static str, operation: impl FnOnce() -> T) -> T {
178 let _progress = Progress::start(label);
182fn progress_line(label: &str, frame: usize, elapsed: Duration, width: usize) -> String {
183 const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
184 let mut line = format!("{} j · {}", FRAMES[frame % FRAMES.len()], label);
185 let seconds = elapsed.as_secs();
186 let time = if seconds < 60 {
187 format!("{}.{}s", seconds, elapsed.subsec_millis() / 100)
189 format!("{}m {:02}s", seconds / 60, seconds % 60)
191 // Bounce a three-cell pulse across a ten-cell track. It never fills up
192 // or suggests a completion percentage for work with no known total.
193 let step = frame % 14;
194 let start = step.min(14 - step);
195 let bar: String = (0..10)
196 .map(|cell| if (start..start + 3).contains(&cell) { '━' } else { '·' })
198 let details = format!(" [{bar}] {time}");
199 if line.width() + details.width() <= width {
200 line.push_str(&details);
201 } else if line.width() + 1 + time.width() <= width {
202 // On small terminals, keep the operation's name ahead of decoration.
204 line.push_str(&time);
209 used += c.width().unwrap_or(0);
215fn stderr_width() -> usize {
216 // The two streams can be attached to different terminals. Measure the
217 // one that receives the progress line, rather than the tree's stdout.
219 let mut size: libc::winsize = std::mem::zeroed();
220 if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut size) == 0
230/// Reveal colored, multiline terminal output in a few quick batches. Never
231/// split a line (and therefore an ANSI style), alter its bytes, or animate
232/// plain text. The total pacing is at most 96 ms, even for very large trees.
233pub fn write_output(out: &mut impl Write, text: &str) -> io::Result<()> {
234 if !motion_enabled() || !text.contains("\x1b[") {
235 return out.write_all(text.as_bytes()).and_then(|()| out.flush());
237 let lines = text.bytes().filter(|&byte| byte == b'\n').count()
238 + usize::from(!text.ends_with('\n'));
239 let batch = lines.div_ceil(7).max(1);
242 for (index, line) in text.split_inclusive('\n').enumerate() {
244 if (index + 1) % batch == 0 && index + 1 < lines {
245 out.write_all(text[start..end].as_bytes())?;
248 thread::sleep(Duration::from_millis(16));
251 out.write_all(text[start..].as_bytes())?;
260 fn progress_fits_terminal_cells_and_keeps_the_operation_visible() {
261 let label = "Reading 仓库";
264 let line = progress_line(label, frame, Duration::from_millis(1234), width);
265 assert!(line.width() <= width, "{width} columns: {line:?}");
267 assert!(line.contains(label), "operation name lost: {line:?}");
271 let line = progress_line("Reading repository", 0, Duration::from_millis(1234), 80);
272 assert!(line.contains("[━━━·······] 1.2s"), "{line:?}");
273 assert!(progress_line("Reading repository", 0, Duration::from_secs(65), 80)
274 .ends_with("1m 05s"));
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list