返回 CodeWhale
osc11.rs
根目录 / crates / tui / src / palette / osc11.rs
1 //! OSC 11 terminal-background query.
2 //!
3 //! `COLORFGBG` is the only background signal the palette had before this
4 //! module, and most modern terminals never set it — Windows Terminal, conhost,
5 //! VS Code, GNOME Terminal, Alacritty and Ghostty all omit it. Without it a
6 //! white terminal was indistinguishable from a black one, so detection fell
7 //! back to `Dark` and painted dark-tuned text onto a light surface (#4833).
8 //!
9 //! OSC 11 (`ESC ] 11 ; ? BEL`) asks the terminal for its actual background
10 //! color and is answered by every terminal listed above. The reply is an
11 //! `xterm`-style color spec, e.g.
12 //!
13 //! ```text
14 //! ESC ] 11 ; rgb:ffff/ffff/ffff ESC \
15 //! ```
16 //!
17 //! The parse is a pure function so it can be tested without a terminal; the
18 //! query itself is Unix-only, bounded by a short deadline, and never runs when
19 //! stdin/stdout are not both TTYs.
20
21 /// Upper bound on how long startup will wait for a terminal that never
22 /// answers. A terminal that supports OSC 11 replies in well under a
23 /// millisecond; anything past this is a terminal that will never reply, and
24 /// startup latency matters more than the answer.
25 pub const OSC11_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(120);
26
27 /// The query sequence. `ESC \` (ST) is the terminator we prefer in the reply,
28 /// but terminals may answer with BEL instead, so the reader accepts both.
29 ///
30 /// Only the Unix query path writes it — see the note on [`parse_osc11_reply`].
31 #[cfg_attr(not(unix), allow(dead_code))]
32 const OSC11_QUERY: &[u8] = b"\x1b]11;?\x1b\\";
33
34 /// Extract an RGB triple from an OSC 11 reply body.
35 ///
36 /// Accepts the shapes terminals actually emit:
37 /// - `rgb:RRRR/GGGG/BBBB` (xterm, 1–4 hex digits per channel, any width)
38 /// - `#RRGGBB` / `#RGB` / `#RRRRGGGGBBBB`
39 ///
40 /// Leading `ESC ] 11 ;` and the trailing BEL/ST are optional — anything
41 /// outside the color spec is ignored, so a reply that arrived interleaved with
42 /// other terminal chatter still parses.
43 ///
44 /// Returns `None` when no color spec is present or a channel is malformed.
45 /// Channels wider than 8 bits are scaled down, not truncated, so `ffff` is
46 /// `255` rather than `0`.
47 // The parser is deliberately cross-platform while the query is Unix-only:
48 // there is no portable way to read a raw OSC reply off a Windows console
49 // handle yet, so on Windows nothing calls these. They are kept (rather than
50 // cfg'd out) because they are pure, fully tested on every platform, and are
51 // exactly what a future Windows read path would need — but that leaves them
52 // dead in a non-test Windows build, which `-D warnings` rejects.
53 #[cfg_attr(not(unix), allow(dead_code))]
54 #[must_use]
55 pub fn parse_osc11_reply(reply: &str) -> Option<(u8, u8, u8)> {
56 if let Some(idx) = reply.find("rgb:") {
57 return parse_slash_separated(&reply[idx + 4..]);
58 }
59 if let Some(idx) = reply.find('#') {
60 return parse_hash_hex(&reply[idx + 1..]);
61 }
62 None
63 }
64
65 #[cfg_attr(not(unix), allow(dead_code))]
66 fn parse_slash_separated(spec: &str) -> Option<(u8, u8, u8)> {
67 let spec: String = spec
68 .chars()
69 .take_while(|c| c.is_ascii_hexdigit() || *c == '/')
70 .collect();
71 let mut parts = spec.split('/');
72 let r = scale_hex_channel(parts.next()?)?;
73 let g = scale_hex_channel(parts.next()?)?;
74 let b = scale_hex_channel(parts.next()?)?;
75 if parts.next().is_some() {
76 return None;
77 }
78 Some((r, g, b))
79 }
80
81 #[cfg_attr(not(unix), allow(dead_code))]
82 fn parse_hash_hex(spec: &str) -> Option<(u8, u8, u8)> {
83 let digits: String = spec.chars().take_while(char::is_ascii_hexdigit).collect();
84 if !digits.len().is_multiple_of(3) || digits.is_empty() || digits.len() > 12 {
85 return None;
86 }
87 let width = digits.len() / 3;
88 let r = scale_hex_channel(&digits[..width])?;
89 let g = scale_hex_channel(&digits[width..width * 2])?;
90 let b = scale_hex_channel(&digits[width * 2..])?;
91 Some((r, g, b))
92 }
93
94 /// Normalize a hex channel of arbitrary width (1–4 digits) to 8 bits by
95 /// rescaling across the channel's full range: `f` → `255`, `ffff` → `255`,
96 /// `8000` → `128`.
97 #[cfg_attr(not(unix), allow(dead_code))]
98 fn scale_hex_channel(digits: &str) -> Option<u8> {
99 if digits.is_empty() || digits.len() > 4 || !digits.chars().all(|c| c.is_ascii_hexdigit()) {
100 return None;
101 }
102 let value = u32::from_str_radix(digits, 16).ok()?;
103 let max = (1u32 << (4 * digits.len() as u32)) - 1;
104 Some(((value * 255 + max / 2) / max) as u8)
105 }
106
107 /// Ask the terminal for its background color, giving up after `timeout`.
108 ///
109 /// Returns `None` — never blocks past `timeout`, never panics — when:
110 /// - stdin and stdout are not both TTYs (piped output, CI, `codewhale < file`),
111 /// - the platform has no supported query path (non-Unix; see the module docs),
112 /// - the terminal does not answer, or answers with something unparsable.
113 ///
114 /// # Caveat
115 ///
116 /// This reads from stdin, so it must only be called while the terminal is in
117 /// raw mode and before the event loop starts. Bytes that arrive during the
118 /// window and are not part of the reply are discarded — at startup that window
119 /// is sub-millisecond on any terminal that answers at all.
120 #[must_use]
121 pub fn query_terminal_background(timeout: std::time::Duration) -> Option<(u8, u8, u8)> {
122 query_impl(timeout)
123 }
124
125 #[cfg(unix)]
126 fn query_impl(timeout: std::time::Duration) -> Option<(u8, u8, u8)> {
127 use std::io::{Read, Write};
128 use std::os::fd::AsRawFd;
129 use std::time::Instant;
130
131 let stdin = std::io::stdin();
132 let stdout = std::io::stdout();
133 let in_fd = stdin.as_raw_fd();
134 let out_fd = stdout.as_raw_fd();
135
136 // SAFETY: `isatty` only inspects the descriptor; both fds are owned by the
137 // std handles held above for the duration of the call.
138 let both_tty = unsafe { libc::isatty(in_fd) == 1 && libc::isatty(out_fd) == 1 };
139 if !both_tty {
140 return None;
141 }
142
143 {
144 let mut out = stdout.lock();
145 out.write_all(OSC11_QUERY).ok()?;
146 out.flush().ok()?;
147 }
148
149 let deadline = Instant::now() + timeout;
150 let mut reply = Vec::with_capacity(32);
151 let mut stdin = stdin.lock();
152 let mut byte = [0u8; 1];
153 loop {
154 let remaining = deadline.saturating_duration_since(Instant::now());
155 if remaining.is_zero() {
156 return None;
157 }
158 if !wait_readable(in_fd, remaining) {
159 return None;
160 }
161 match stdin.read(&mut byte) {
162 Ok(1) => {}
163 _ => return None,
164 }
165 // BEL, or the ESC of a `ESC \` string terminator, ends the reply.
166 if byte[0] == 0x07 || (byte[0] == 0x1b && !reply.is_empty()) {
167 break;
168 }
169 reply.push(byte[0]);
170 if reply.len() >= 128 {
171 return None;
172 }
173 }
174
175 parse_osc11_reply(&String::from_utf8_lossy(&reply))
176 }
177
178 /// Block until `fd` has data or `timeout` elapses. `true` means readable.
179 #[cfg(unix)]
180 fn wait_readable(fd: std::os::fd::RawFd, timeout: std::time::Duration) -> bool {
181 let mut pollfd = libc::pollfd {
182 fd,
183 events: libc::POLLIN,
184 revents: 0,
185 };
186 let millis = i32::try_from(timeout.as_millis())
187 .unwrap_or(i32::MAX)
188 .max(1);
189 // SAFETY: `pollfd` is a live, correctly-initialized single-element array
190 // and the count matches.
191 let rc = unsafe { libc::poll(std::ptr::addr_of_mut!(pollfd), 1, millis) };
192 rc > 0 && (pollfd.revents & libc::POLLIN) != 0
193 }
194
195 /// Non-Unix platforms have no portable way to read a raw OSC reply back off
196 /// the console handle, so detection falls through to the environment-based
197 /// sources. Callers treat `None` as "no evidence", never as "dark".
198 #[cfg(not(unix))]
199 fn query_impl(_timeout: std::time::Duration) -> Option<(u8, u8, u8)> {
200 None
201 }
202
202 lines RUST