Skip to main content

rch_common/ui/progress/
mod.rs

1//! Progress context for safe, rate-limited terminal updates.
2//!
3//! This module provides a low-level progress context that focuses on
4//! terminal safety: rate limiting, cursor visibility management, and
5//! clean teardown on interrupts. It is intentionally simple and ASCII-only
6//! to avoid leaving partial escape sequences in mixed-output scenarios.
7
8mod 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/// Progress context with safe terminal behavior.
291#[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    /// Render a single progress line (rate-limited).
334    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    /// Clear the current progress line.
364    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;