Skip to main content

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}