atomcode_tuix/terminal_bg.rs
1//! OSC 11 terminal-background-colour detection.
2//!
3//! Queries the active terminal for its background colour and decides
4//! light vs. dark. Used at startup when `Config::ui.theme == Auto` to
5//! pick the right colour palette. On terminals that don't respond
6//! (macOS Terminal.app, Windows conhost), returns `None` and the
7//! caller falls back to the legacy dark palette.
8//!
9//! Must be called with raw mode active — otherwise the response is
10//! line-buffered by the kernel and never reaches us within the timeout.
11
12use std::time::Duration;
13
14/// Query the terminal for its background colour and decide light vs.
15/// dark. Returns `Some(true)` for light, `Some(false)` for dark,
16/// `None` when the terminal didn't respond within `timeout`.
17///
18/// Implementation (Unix):
19/// 1. Write OSC 11 query (`ESC ] 11 ; ? BEL`) to stdout.
20/// 2. Wait up to `timeout` for stdin to become readable
21/// (`libc::poll`, single fd).
22/// 3. Read available bytes via `libc::read`, parse the
23/// `rgb:RRRR/GGGG/BBBB` payload.
24/// 4. Compute Rec. 709 relative luminance, threshold at 128/255.
25///
26/// Windows / non-Unix: returns `None` immediately. Windows conhost
27/// doesn't respond to OSC 11 at all; Windows Terminal does but the
28/// `libc::poll` path isn't available there. A future improvement can
29/// add a Win32-specific path via PeekConsoleInput.
30pub fn detect_light(timeout: Duration) -> Option<bool> {
31 #[cfg(unix)]
32 {
33 detect_light_unix(timeout)
34 }
35 #[cfg(not(unix))]
36 {
37 let _ = timeout;
38 None
39 }
40}
41
42#[cfg(unix)]
43fn detect_light_unix(timeout: Duration) -> Option<bool> {
44 use std::io::Write;
45 use std::os::unix::io::AsRawFd;
46
47 let mut stdout = std::io::stdout();
48 // OSC 11 query — request background colour. BEL terminator
49 // (`\x07`) is the de-facto default for xterm-family terminals;
50 // emulators that prefer ST (`\x1b\\`) accept BEL too in practice.
51 stdout.write_all(b"\x1b]11;?\x07").ok()?;
52 stdout.flush().ok()?;
53
54 let stdin = std::io::stdin();
55 let fd = stdin.as_raw_fd();
56
57 // Two-phase budget.
58 //
59 // Phase 1 (main read): caller's contract — up to `timeout` for the
60 // FIRST byte. If nothing arrives in this window the terminal is
61 // non-responsive (macOS Terminal.app, classic conhost, OSC-stripping
62 // SSH relays) and we fall straight through to the dark-mode default
63 // without blocking startup further. If we DO see an OSC opener
64 // (`\x1b]`) within `timeout`, we extend by 250ms so the trailing
65 // bytes of a slow / chunked response can land — without this a
66 // partial OSC 11 reply (e.g. JediTerm, remote relays, Windows
67 // Terminal under load) leaks past the original single-shot read
68 // and the crossterm reader thread later picks those bytes up as
69 // keystrokes. The visible bug was `]11;rgb:0000/0000/0000\` (and
70 // shorter prefixes like `0c/0c0c\`) appearing in the input box.
71 //
72 // Phase 2 (tail-drain): handles the case where the OSC response
73 // started arriving AFTER `timeout` — Phase 1 already broke out
74 // empty-handed, but the bytes are about to land. We spend an extra
75 // 80ms peeking; we only commit to bulk-draining once we've confirmed
76 // a `\x1b]` opener, so a user keystroke that happens to land in
77 // this window loses at most one or two bytes (vs. the bulk read in
78 // Phase 1 which would swallow up to 128). In practice the input
79 // prompt isn't on screen during the 100–180ms window so the user
80 // hasn't started typing yet, but the byte-at-a-time probe keeps
81 // the worst case bounded if they have.
82 let start = std::time::Instant::now();
83 let initial_deadline = start + timeout;
84 let extended_deadline = initial_deadline + Duration::from_millis(250);
85
86 let mut buf: Vec<u8> = Vec::with_capacity(64);
87 let mut saw_osc_start = false;
88
89 // Phase 1.
90 loop {
91 let deadline = if saw_osc_start { extended_deadline } else { initial_deadline };
92 let mut chunk = [0u8; 128];
93 // SAFETY: chunk is stack-allocated and lives for the call; fd
94 // is owned by stdin for the process lifetime.
95 let nread = unsafe { poll_read(fd, deadline, &mut chunk) };
96 if nread == 0 {
97 break;
98 }
99 buf.extend_from_slice(&chunk[..nread]);
100
101 // Lock in the deadline extension the first time we see the OSC
102 // opener; from here on Phase 1 keeps draining until terminator.
103 if !saw_osc_start {
104 saw_osc_start = buf.windows(2).any(|w| w == b"\x1b]");
105 // First bytes weren't an OSC opener — almost certainly a
106 // stray keystroke that landed in the input queue before
107 // our query. Don't keep slurping their input in bulk-read
108 // mode; if the OSC reply is still coming, Phase 2 will
109 // catch it.
110 if !saw_osc_start {
111 break;
112 }
113 }
114
115 if has_osc_terminator(&buf) {
116 break;
117 }
118 }
119
120 // Phase 2: tail-drain. Only runs when Phase 1 didn't already
121 // consume a complete OSC reply (terminator absent from `buf`).
122 if !has_osc_terminator(&buf) {
123 let tail_deadline = std::time::Instant::now() + Duration::from_millis(80);
124 // If Phase 1 already saw the opener, we're committed to bulk
125 // draining. Otherwise we probe a byte at a time.
126 let mut committed = saw_osc_start;
127 loop {
128 if committed {
129 let mut chunk = [0u8; 128];
130 // SAFETY: same invariants as the Phase 1 read.
131 let nread = unsafe { poll_read(fd, tail_deadline, &mut chunk) };
132 if nread == 0 {
133 break;
134 }
135 buf.extend_from_slice(&chunk[..nread]);
136 } else {
137 // Peek the first byte. Anything other than ESC means
138 // it's not an OSC reply — stop immediately so we don't
139 // keep eating user input.
140 let mut probe = [0u8; 1];
141 // SAFETY: see Phase 1.
142 let nread = unsafe { poll_read(fd, tail_deadline, &mut probe) };
143 if nread == 0 {
144 break;
145 }
146 if probe[0] != b'\x1b' {
147 buf.push(probe[0]);
148 break;
149 }
150 buf.push(probe[0]);
151 // ESC seen — disambiguate `\x1b]` (OSC, what we want)
152 // from `\x1b[` / `\x1bO` / bare ESC (which we leave
153 // alone). The terminal may send ESC and pause briefly
154 // before the next byte; poll again with what's left
155 // of the tail budget.
156 let mut probe2 = [0u8; 1];
157 // SAFETY: see Phase 1.
158 let nread2 = unsafe { poll_read(fd, tail_deadline, &mut probe2) };
159 if nread2 == 0 {
160 break;
161 }
162 buf.push(probe2[0]);
163 if probe2[0] != b']' {
164 break;
165 }
166 committed = true;
167 }
168
169 if has_osc_terminator(&buf) {
170 break;
171 }
172 }
173 }
174
175 parse_osc11_response(&buf)
176}
177
178/// True when `buf` contains an OSC terminator (BEL or ESC \). Used by
179/// both phases of [`detect_light_unix`] to know when an in-flight OSC
180/// reply is fully drained.
181#[cfg(unix)]
182fn has_osc_terminator(buf: &[u8]) -> bool {
183 buf.contains(&b'\x07') || buf.windows(2).any(|w| w == b"\x1b\\")
184}
185
186/// Wait until `deadline` for `fd` to be readable, then read up to
187/// `out.len()` bytes into `out`. Returns the number of bytes read, or
188/// `0` on timeout / poll error / EOF / read error. Factored out so
189/// the two phases of [`detect_light_unix`] share one poll-then-read
190/// path with identical clamping and SAFETY invariants.
191///
192/// # Safety
193/// `fd` must be a valid file descriptor for the entire call; `out`
194/// must be writable for `out.len()` bytes.
195#[cfg(unix)]
196unsafe fn poll_read(fd: i32, deadline: std::time::Instant, out: &mut [u8]) -> usize {
197 let now = std::time::Instant::now();
198 if now >= deadline {
199 return 0;
200 }
201 // poll() ms argument is i32; clamp to its range.
202 let ms = (deadline - now).as_millis().min(i32::MAX as u128) as i32;
203 let mut pollfd = libc::pollfd {
204 fd,
205 events: libc::POLLIN,
206 revents: 0,
207 };
208 let n = libc::poll(&mut pollfd, 1, ms);
209 if n <= 0 {
210 return 0;
211 }
212 let nread = libc::read(fd, out.as_mut_ptr() as *mut libc::c_void, out.len());
213 if nread <= 0 {
214 0
215 } else {
216 nread as usize
217 }
218}
219
220/// Parse an OSC 11 reply of shape `ESC ] 11 ; rgb:RRRR/GGGG/BBBB BEL`
221/// (or `ESC \\` ST terminator). Returns `Some(is_light)` when a usable
222/// RGB triplet is found.
223///
224/// Tolerates leading garbage (pre-existing keystrokes in stdin) by
225/// scanning for `rgb:`. Tolerates trailing garbage (BEL / ST / partial
226/// next response) by stopping at the first non-hex char.
227pub(crate) fn parse_osc11_response(bytes: &[u8]) -> Option<bool> {
228 // Allow non-UTF-8 prefix bytes (a stray keystroke could be any
229 // byte); slice to the start of `rgb:` and parse from there as
230 // ASCII (which it is — the OSC 11 reply is pure ASCII).
231 let needle = b"rgb:";
232 let rgb_pos = bytes.windows(needle.len()).position(|w| w == needle)?;
233 let after = std::str::from_utf8(&bytes[rgb_pos + needle.len()..]).ok()?;
234
235 let mut parts = after.split('/');
236 let r_raw = parts.next()?;
237 let g_raw = parts.next()?;
238 let b_raw = parts.next()?;
239
240 let r = parse_hex_component(r_raw)?;
241 let g = parse_hex_component(g_raw)?;
242 let b = parse_hex_component(b_raw)?;
243
244 // Rec. 709 relative luminance, components in 0..=255.
245 let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
246 Some(lum > 128.0)
247}
248
249/// Parse one OSC 11 colour component. xterm returns 4 hex chars (16-bit
250/// precision); some emulators return 2 (8-bit) or even 1. Reads
251/// hex-digit-prefix-only and normalises to 0..=255 based on observed
252/// width — so `rgb:ff/ff/ff` and `rgb:ffff/ffff/ffff` both come out
253/// as 255.0.
254fn parse_hex_component(s: &str) -> Option<f64> {
255 let hex: String = s.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
256 if hex.is_empty() {
257 return None;
258 }
259 let val = u32::from_str_radix(&hex, 16).ok()?;
260 // 4-char hex → max 0xFFFF = 65535; 2-char → 0xFF = 255; 1-char → 0xF = 15.
261 let max = (1u64 << (4 * hex.len())).saturating_sub(1) as u32;
262 if max == 0 {
263 return None;
264 }
265 Some((val as f64 * 255.0) / max as f64)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn parses_pure_white_as_light() {
274 let response = b"\x1b]11;rgb:ffff/ffff/ffff\x07";
275 assert_eq!(parse_osc11_response(response), Some(true));
276 }
277
278 #[test]
279 fn parses_pure_black_as_dark() {
280 let response = b"\x1b]11;rgb:0000/0000/0000\x07";
281 assert_eq!(parse_osc11_response(response), Some(false));
282 }
283
284 #[test]
285 fn parses_8bit_response() {
286 // Some emulators (older xterm builds) return 2-hex-char per channel.
287 let response = b"\x1b]11;rgb:ff/ff/ff\x07";
288 assert_eq!(parse_osc11_response(response), Some(true));
289 }
290
291 #[test]
292 fn parses_vscode_dark_plus() {
293 // VSCode "Dark+" editor background ≈ #1E1E1E (30,30,30).
294 let response = b"\x1b]11;rgb:1e1e/1e1e/1e1e\x07";
295 assert_eq!(parse_osc11_response(response), Some(false));
296 }
297
298 #[test]
299 fn parses_vscode_light_plus() {
300 // VSCode "Light+" editor background ≈ #FFFFFF.
301 let response = b"\x1b]11;rgb:ffff/ffff/ffff\x07";
302 assert_eq!(parse_osc11_response(response), Some(true));
303 }
304
305 #[test]
306 fn parses_st_terminator() {
307 // ESC \ is the spec-correct terminator; some emulators (notably
308 // st itself) emit it instead of BEL.
309 let response = b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\";
310 assert_eq!(parse_osc11_response(response), Some(true));
311 }
312
313 #[test]
314 fn tolerates_leading_garbage() {
315 // A stray keystroke landed in stdin before the OSC reply.
316 let response = b"q\x1b]11;rgb:ffff/ffff/ffff\x07";
317 assert_eq!(parse_osc11_response(response), Some(true));
318 }
319
320 #[test]
321 fn rejects_no_rgb_prefix() {
322 assert_eq!(parse_osc11_response(b""), None);
323 assert_eq!(parse_osc11_response(b"random bytes"), None);
324 assert_eq!(parse_osc11_response(b"\x1b[A"), None); // arrow key
325 }
326
327 #[test]
328 fn rejects_truncated_response() {
329 assert_eq!(parse_osc11_response(b"\x1b]11;rgb:"), None);
330 assert_eq!(parse_osc11_response(b"\x1b]11;rgb:ff/"), None);
331 assert_eq!(parse_osc11_response(b"\x1b]11;rgb:ff/ff"), None);
332 }
333
334 #[test]
335 fn threshold_at_50_percent_grey_is_dark() {
336 // Pure 50% grey: lum = 128 exactly. `> 128` means 128 stays
337 // dark. Pin this so a refactor doesn't flip the boundary
338 // (typical "near-50% grey theme" should default to dark since
339 // most users intend dark with mid-grey backgrounds).
340 let response = b"\x1b]11;rgb:8080/8080/8080\x07";
341 assert_eq!(parse_osc11_response(response), Some(false));
342 }
343
344 #[test]
345 fn threshold_one_above_50_percent_grey_is_light() {
346 // 129/255 → luminance just over 128 → light.
347 let response = b"\x1b]11;rgb:8181/8181/8181\x07";
348 assert_eq!(parse_osc11_response(response), Some(true));
349 }
350
351 #[test]
352 fn luminance_weights_green_more_than_red_or_blue() {
353 // Rec. 709: G dominates. Pure green should be brighter than
354 // pure red. (255 * 0.7152 = 182.4 > 128 → light.)
355 let pure_green = b"\x1b]11;rgb:0000/ffff/0000\x07";
356 assert_eq!(parse_osc11_response(pure_green), Some(true));
357
358 // Pure red: 255 * 0.2126 = 54.2 → dark.
359 let pure_red = b"\x1b]11;rgb:ffff/0000/0000\x07";
360 assert_eq!(parse_osc11_response(pure_red), Some(false));
361
362 // Pure blue: 255 * 0.0722 = 18.4 → dark.
363 let pure_blue = b"\x1b]11;rgb:0000/0000/ffff\x07";
364 assert_eq!(parse_osc11_response(pure_blue), Some(false));
365 }
366}