rch_common/ui/progress/
mod.rs1mod celebrate;
9mod compile;
10mod pipeline;
11mod spinner;
12mod transfer;
13
14use crate::ui::OutputContext;
15use std::io::{IsTerminal, Write};
16use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
17use std::sync::{Mutex, OnceLock};
18use std::time::Instant;
19
20pub use celebrate::{ArtifactSummary, CelebrationSummary, CompletionCelebration};
21pub use compile::{BuildPhase, BuildProfile, CompilationProgress, CrateInfo};
22pub use pipeline::{PipelineProgress, PipelineStage, StageStatus};
23pub use spinner::{AnimatedSpinner, SpinnerResult, SpinnerStyle};
24pub use transfer::{TransferDirection, TransferProgress, TransferStats};
25
26const DEFAULT_TERMINAL_WIDTH: u16 = 80;
27const MAX_UPDATES_PER_SEC: u32 = 10;
28const CLEAR_LINE: &str = "\r\x1b[2K";
29const HIDE_CURSOR: &str = "\x1b[?25l";
30const SHOW_CURSOR: &str = "\x1b[?25h";
31
32static ACTIVE_CONTEXTS: AtomicUsize = AtomicUsize::new(0);
33static RENDER_LOCK: Mutex<()> = Mutex::new(());
34
35#[derive(Debug)]
36struct SignalState {
37 interrupted: AtomicBool,
38 resized: AtomicBool,
39}
40
41impl SignalState {
42 fn new() -> Self {
43 Self {
44 interrupted: AtomicBool::new(false),
45 resized: AtomicBool::new(false),
46 }
47 }
48
49 fn mark_interrupted(&self) {
50 self.interrupted.store(true, Ordering::SeqCst);
51 }
52
53 fn mark_resized(&self) {
54 self.resized.store(true, Ordering::SeqCst);
55 }
56
57 fn take_resized(&self) -> bool {
58 self.resized.swap(false, Ordering::SeqCst)
59 }
60
61 #[cfg(test)]
62 fn simulate_interrupt(&self) {
63 self.mark_interrupted();
64 }
65
66 #[cfg(test)]
67 fn simulate_resize(&self) {
68 self.mark_resized();
69 }
70}
71
72static SIGNAL_STATE: OnceLock<std::sync::Arc<SignalState>> = OnceLock::new();
73
74fn ensure_signal_state() -> std::sync::Arc<SignalState> {
75 SIGNAL_STATE
76 .get_or_init(|| {
77 let state = std::sync::Arc::new(SignalState::new());
78 start_signal_listener(state.clone());
79 state
80 })
81 .clone()
82}
83
84fn start_signal_listener(state: std::sync::Arc<SignalState>) {
85 #[cfg(unix)]
86 {
87 std::thread::spawn(move || {
88 let runtime = tokio::runtime::Builder::new_current_thread()
89 .enable_all()
90 .build();
91 let Ok(runtime) = runtime else {
92 return;
93 };
94
95 runtime.block_on(async move {
96 use tokio::signal::unix::{SignalKind, signal};
97
98 let mut sigint = match signal(SignalKind::interrupt()) {
99 Ok(sig) => sig,
100 Err(_) => return,
101 };
102 let mut sigterm = match signal(SignalKind::terminate()) {
103 Ok(sig) => sig,
104 Err(_) => return,
105 };
106 let mut sigwinch = match signal(SignalKind::window_change()) {
107 Ok(sig) => sig,
108 Err(_) => return,
109 };
110
111 loop {
112 tokio::select! {
113 _ = sigint.recv() => {
114 state.mark_interrupted();
115 cleanup_terminal_if_active();
116 }
117 _ = sigterm.recv() => {
118 state.mark_interrupted();
119 cleanup_terminal_if_active();
120 }
121 _ = sigwinch.recv() => {
122 state.mark_resized();
123 }
124 }
125 }
126 });
127 });
128 }
129}
130
131fn cleanup_terminal_if_active() {
132 if ACTIVE_CONTEXTS.load(Ordering::SeqCst) == 0 {
133 return;
134 }
135
136 let _lock = RENDER_LOCK.lock();
137 let mut buffer = String::new();
138 buffer.push_str(CLEAR_LINE);
139 buffer.push_str(SHOW_CURSOR);
140 let _ = write_stderr(&buffer);
141}
142
143fn write_stderr(text: &str) -> std::io::Result<()> {
144 let mut stderr = std::io::stderr();
145 stderr.write_all(text.as_bytes())?;
146 stderr.flush()
147}
148
149fn detect_terminal_width_with<F>(get_env: F) -> u16
150where
151 F: Fn(&str) -> Option<String>,
152{
153 get_env("COLUMNS")
154 .and_then(|value| value.parse::<u16>().ok())
155 .filter(|value| *value > 0)
156 .unwrap_or(DEFAULT_TERMINAL_WIDTH)
157}
158
159fn detect_terminal_width() -> u16 {
160 detect_terminal_width_with(|key| std::env::var(key).ok())
161}
162
163#[derive(Debug)]
164pub struct RateLimiter {
165 start: Instant,
166 min_interval_ns: u64,
167 last_ns: AtomicU64,
168}
169
170impl RateLimiter {
171 #[must_use]
172 pub fn new(max_per_sec: u32) -> Self {
173 let per_sec = max_per_sec.max(1) as u64;
174 let min_interval_ns = 1_000_000_000u64 / per_sec;
175 Self {
176 start: Instant::now(),
177 min_interval_ns,
178 last_ns: AtomicU64::new(u64::MAX),
179 }
180 }
181
182 pub fn allow(&self) -> bool {
183 let now_ns = self.now_ns();
184 self.allow_with(now_ns)
185 }
186
187 pub fn reset(&self) {
188 self.last_ns.store(u64::MAX, Ordering::SeqCst);
189 }
190
191 fn now_ns(&self) -> u64 {
192 let elapsed = self.start.elapsed();
193 let nanos = elapsed.as_nanos();
194 nanos.min(u128::from(u64::MAX)) as u64
195 }
196
197 fn allow_with(&self, now_ns: u64) -> bool {
198 let last = self.last_ns.load(Ordering::Relaxed);
199 if last == u64::MAX {
200 return self
201 .last_ns
202 .compare_exchange(u64::MAX, now_ns, Ordering::SeqCst, Ordering::Relaxed)
203 .is_ok();
204 }
205 if now_ns.saturating_sub(last) < self.min_interval_ns {
206 return false;
207 }
208
209 self.last_ns
210 .compare_exchange(last, now_ns, Ordering::SeqCst, Ordering::Relaxed)
211 .is_ok()
212 }
213
214 #[cfg(test)]
215 fn allow_at(&self, now_ns: u64) -> bool {
216 self.allow_with(now_ns)
217 }
218
219 #[cfg(test)]
220 fn min_interval_ns(&self) -> u64 {
221 self.min_interval_ns
222 }
223}
224
225#[derive(Debug)]
226struct TerminalState {
227 width: u16,
228 #[allow(dead_code)]
229 cursor_hidden: bool,
230}
231
232impl TerminalState {
233 fn new() -> Self {
234 Self {
235 width: detect_terminal_width(),
236 cursor_hidden: false,
237 }
238 }
239
240 fn refresh_width(&mut self) {
241 self.width = detect_terminal_width();
242 }
243
244 fn truncate(&self, line: &str) -> String {
245 let width = self.width.max(1) as usize;
246 line.chars().take(width).collect()
247 }
248
249 #[cfg(test)]
250 fn refresh_width_with<F>(&mut self, get_env: F)
251 where
252 F: Fn(&str) -> Option<String>,
253 {
254 self.width = detect_terminal_width_with(get_env);
255 }
256}
257
258#[derive(Debug)]
259struct CleanupGuard {
260 enabled: bool,
261}
262
263impl CleanupGuard {
264 fn new(enabled: bool) -> Self {
265 Self { enabled }
266 }
267
268 fn clear_line(&self) {
269 if self.enabled {
270 let _lock = RENDER_LOCK.lock();
271 let _ = write_stderr(CLEAR_LINE);
272 }
273 }
274
275 fn hide_cursor(&self) {
276 if self.enabled {
277 let _lock = RENDER_LOCK.lock();
278 let _ = write_stderr(HIDE_CURSOR);
279 }
280 }
281
282 fn show_cursor(&self) {
283 if self.enabled {
284 let _lock = RENDER_LOCK.lock();
285 let _ = write_stderr(SHOW_CURSOR);
286 }
287 }
288}
289
290#[derive(Debug)]
292pub struct ProgressContext {
293 rate_limiter: RateLimiter,
294 terminal: TerminalState,
295 cleanup_guard: CleanupGuard,
296 signal_state: Option<std::sync::Arc<SignalState>>,
297 enabled: bool,
298}
299
300impl ProgressContext {
301 #[must_use]
302 pub fn new(ctx: OutputContext) -> Self {
303 let stderr_is_tty = std::io::stderr().is_terminal();
304 Self::new_with_options(ctx, stderr_is_tty, true)
305 }
306
307 fn new_with_options(ctx: OutputContext, stderr_is_tty: bool, listen_signals: bool) -> Self {
308 let enabled = stderr_is_tty && matches!(ctx, OutputContext::Interactive);
309 let cleanup_guard = CleanupGuard::new(enabled);
310
311 if enabled {
312 let previous = ACTIVE_CONTEXTS.fetch_add(1, Ordering::SeqCst);
313 if previous == 0 {
314 cleanup_guard.hide_cursor();
315 }
316 }
317
318 let signal_state = if enabled && listen_signals {
319 Some(ensure_signal_state())
320 } else {
321 None
322 };
323
324 Self {
325 rate_limiter: RateLimiter::new(MAX_UPDATES_PER_SEC),
326 terminal: TerminalState::new(),
327 cleanup_guard,
328 signal_state,
329 enabled,
330 }
331 }
332
333 pub fn render(&mut self, line: &str) {
335 if !self.enabled {
336 return;
337 }
338
339 if !self.rate_limiter.allow() {
340 return;
341 }
342
343 if let Some(state) = &self.signal_state {
344 if state.interrupted.load(Ordering::SeqCst) {
345 self.cleanup_guard.clear_line();
346 self.cleanup_guard.show_cursor();
347 return;
348 }
349
350 if state.take_resized() {
351 self.terminal.refresh_width();
352 }
353 }
354
355 let rendered = self.terminal.truncate(line);
356 let _lock = RENDER_LOCK.lock();
357 let mut buffer = String::new();
358 buffer.push_str(CLEAR_LINE);
359 buffer.push_str(&rendered);
360 let _ = write_stderr(&buffer);
361 }
362
363 pub fn clear(&self) {
365 self.cleanup_guard.clear_line();
366 }
367
368 #[cfg(test)]
369 fn new_for_test(stderr_is_tty: bool) -> Self {
370 Self::new_with_options(OutputContext::Interactive, stderr_is_tty, false)
371 }
372}
373
374impl Drop for ProgressContext {
375 fn drop(&mut self) {
376 if !self.enabled {
377 return;
378 }
379
380 let previous = ACTIVE_CONTEXTS.fetch_sub(1, Ordering::SeqCst);
381 if previous == 1 {
382 self.cleanup_guard.clear_line();
383 self.cleanup_guard.show_cursor();
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests;