on master help sign in
j/src/terminal.rs
276 lines 9.6 kB history raw
1//! Interactive presentation. Rendered values stay free of cursor controls;
2//! motion belongs only to the CLI's final output and temporary status line.
4use std::fmt;
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);
17fn stop_progress() {
18 let worker = ACTIVE_PROGRESS.lock().unwrap().as_ref().and_then(Weak::upgrade);
19 if let Some(worker) = worker {
20 worker.finish();
21 }
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);
28 stop_progress();
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 {
46 interactive()
47 && capable_terminal()
48 && !matches!(
49 std::env::var("J_ANIMATIONS")
50 .unwrap_or_default()
51 .to_ascii_lowercase()
52 .as_str(),
53 "never" | "off" | "false" | "0"
54 )
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) {
67 stop_progress();
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}");
73 } else {
74 let _ = writeln!(out, "\x1b[2m{message}\x1b[0m");
75 }
76 } else {
77 let _ = writeln!(out, "{args}");
78 }
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) {
84 stop_progress();
85 if interactive() {
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}");
89 } else {
90 let _ = writeln!(out, "j: {message}");
91 }
92 }
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.
100struct Progress {
101 worker: Option<Arc<ProgressWorker>>,
104struct ProgressWorker {
105 stop: Sender<()>,
106 thread: Mutex<Option<JoinHandle<()>>>,
109impl ProgressWorker {
110 fn finish(&self) {
111 let _ = self.stop.send(());
112 if let Some(worker) = self.thread.lock().unwrap().take() {
113 let _ = worker.join();
114 }
115 }
118impl Progress {
119 fn start(label: &'static str) -> Self {
120 if !motion_enabled() || EXTERNAL_STDERR.load(Ordering::Relaxed) {
121 return Self { worker: None };
122 }
123 stop_progress();
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) {
128 return;
129 }
130 let colors = ["95", "96", "93", "92", "94"];
131 let mut frame = 0;
132 loop {
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);
137 {
138 let mut out = io::stderr().lock();
139 if write!(
140 out,
141 "\r\x1b[2K\x1b[1;{}m{}\x1b[0m",
142 colors[frame % colors.len()],
143 line
144 )
145 .and_then(|()| out.flush())
146 .is_err()
147 {
148 break;
149 }
150 }
151 frame += 1;
152 if receive.recv_timeout(Duration::from_millis(75)) != Err(mpsc::RecvTimeoutError::Timeout) {
153 break;
154 }
155 }
156 let mut out = io::stderr().lock();
157 let _ = write!(out, "\r\x1b[2K").and_then(|()| out.flush());
158 });
159 let worker = worker.ok().map(|worker| Arc::new(ProgressWorker {
160 stop: send,
161 thread: Mutex::new(Some(worker)),
162 }));
163 *ACTIVE_PROGRESS.lock().unwrap() = worker.as_ref().map(Arc::downgrade);
164 Self { worker }
165 }
168impl Drop for Progress {
169 fn drop(&mut self) {
170 if let Some(worker) = self.worker.take() {
171 worker.finish();
172 }
173 }
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);
179 operation()
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)
188 } else {
189 format!("{}m {:02}s", seconds / 60, seconds % 60)
190 };
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 { '·' })
197 .collect();
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.
203 line.push(' ');
204 line.push_str(&time);
205 }
206 let mut used = 0;
207 line.chars()
208 .take_while(|c| {
209 used += c.width().unwrap_or(0);
210 used <= width
211 })
212 .collect()
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.
218 unsafe {
219 let mut size: libc::winsize = std::mem::zeroed();
220 if libc::ioctl(libc::STDERR_FILENO, libc::TIOCGWINSZ, &mut size) == 0
221 && size.ws_col > 0
222 {
223 size.ws_col as usize
224 } else {
225 80
226 }
227 }
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());
236 }
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);
240 let mut start = 0;
241 let mut end = 0;
242 for (index, line) in text.split_inclusive('\n').enumerate() {
243 end += line.len();
244 if (index + 1) % batch == 0 && index + 1 < lines {
245 out.write_all(text[start..end].as_bytes())?;
246 out.flush()?;
247 start = end;
248 thread::sleep(Duration::from_millis(16));
249 }
250 }
251 out.write_all(text[start..].as_bytes())?;
252 out.flush()
255#[cfg(test)]
256mod tests {
257 use super::*;
259 #[test]
260 fn progress_fits_terminal_cells_and_keeps_the_operation_visible() {
261 let label = "Reading 仓库";
262 for width in 0..80 {
263 for frame in 0..20 {
264 let line = progress_line(label, frame, Duration::from_millis(1234), width);
265 assert!(line.width() <= width, "{width} columns: {line:?}");
266 if width >= 18 {
267 assert!(line.contains(label), "operation name lost: {line:?}");
268 }
269 }
270 }
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"));
275 }