1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
//! Native terminal progress via the OSC 9;4 sequence.
//!
//! OSC 9;4 (originated by ConEmu) drives the terminal's *own* progress
//! indicator — Ghostty renders a bar across the top of the window, Windows
//! Terminal and ConEmu light the taskbar, WezTerm/Konsole/mintty support it
//! too. It is out-of-band: it moves no cursor and paints no cells, so it works
//! in both the inline and full-screen renderers, and terminals that don't
//! understand it silently swallow the unknown OSC.
//!
//! Sequence: `ESC ] 9 ; 4 ; <state> ; <progress> BEL`, where `progress` is
//! 0–100 and `state` is one of [`ProgressState`].
use std::io::{self, Write};
/// OSC 9;4 progress states.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProgressState {
/// Hide the indicator (progress ignored).
Clear,
/// Normal determinate progress at the given percent.
Normal,
/// Error state (typically red), at the given percent.
Error,
/// Indeterminate / busy (percent ignored).
Indeterminate,
/// Warning state (typically yellow), at the given percent.
Warning,
}
impl ProgressState {
fn code(self) -> u8 {
match self {
ProgressState::Clear => 0,
ProgressState::Normal => 1,
ProgressState::Error => 2,
ProgressState::Indeterminate => 3,
ProgressState::Warning => 4,
}
}
}
/// Encode an OSC 9;4 progress sequence. Pure and unit-testable — no I/O.
pub fn encode(state: ProgressState, percent: u8) -> String {
format!("\x1b]9;4;{};{}\x07", state.code(), percent.min(100))
}
/// Drives the terminal's native progress indicator, clearing it on drop so a
/// dropped or panicking session never leaves a stuck bar in the taskbar.
pub struct TerminalProgress {
/// Tracks whether an indicator is currently shown, to avoid redundant
/// writes and to know whether `Drop` must clear.
active: bool,
}
impl TerminalProgress {
/// Create a driver with no indicator shown yet.
pub fn new() -> Self {
Self { active: false }
}
fn write(&mut self, state: ProgressState, percent: u8) {
// Best-effort: a failed progress write must never disrupt the session.
let mut out = io::stdout();
if out.write_all(encode(state, percent).as_bytes()).is_ok() {
let _ = out.flush();
}
self.active = state != ProgressState::Clear;
}
/// Show the busy/indeterminate indicator.
pub fn indeterminate(&mut self) {
self.write(ProgressState::Indeterminate, 0);
}
/// Show determinate progress at `percent` (0–100).
pub fn percent(&mut self, percent: u8) {
self.write(ProgressState::Normal, percent);
}
/// Show the error indicator at `percent`.
pub fn error(&mut self, percent: u8) {
self.write(ProgressState::Error, percent);
}
/// Hide the indicator. Idempotent.
pub fn clear(&mut self) {
if self.active {
self.write(ProgressState::Clear, 0);
}
}
/// Whether an indicator is currently shown.
pub fn is_active(&self) -> bool {
self.active
}
}
impl Default for TerminalProgress {
fn default() -> Self {
Self::new()
}
}
impl Drop for TerminalProgress {
fn drop(&mut self) {
self.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn osc_progress_encoding() {
// ESC ] 9 ; 4 ; state ; percent BEL
assert_eq!(encode(ProgressState::Indeterminate, 0), "\x1b]9;4;3;0\x07");
assert_eq!(encode(ProgressState::Normal, 50), "\x1b]9;4;1;50\x07");
assert_eq!(encode(ProgressState::Clear, 0), "\x1b]9;4;0;0\x07");
assert_eq!(encode(ProgressState::Error, 12), "\x1b]9;4;2;12\x07");
// Percent is clamped to 100.
assert_eq!(encode(ProgressState::Normal, 200), "\x1b]9;4;1;100\x07");
}
}