返回 CodeWhale
osc11.rs
根目录 / crates / palette / src / 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 *not* discarded: they are the
119 /// user's type-ahead, and are handed to
120 /// [`carry_typed_ahead`] for the event loop to
121 /// replay in order (#5925).
122 #[must_use]
123 pub fn query_terminal_background(timeout: std::time::Duration) -> Option<(u8, u8, u8)> {
124 let reply = query_terminal(OSC11_QUERY, timeout)?;
125 parse_osc11_reply(&String::from_utf8_lossy(&reply))
126 }
127
128 /// Write `query` to the terminal and read back one reply, giving up after
129 /// `timeout`. The reply is the bytes up to (not including) its BEL or `ESC \`
130 /// terminator; an `ESC` that opens the reply is kept. Shared by the OSC 11
131 /// background query and the kitty graphics probe (`tui::mark`), under the
132 /// same caveat as [`query_terminal_background`]: raw mode on, event loop not
133 /// yet reading stdin.
134 #[cfg(unix)]
135 pub fn query_terminal(query: &[u8], timeout: std::time::Duration) -> Option<Vec<u8>> {
136 query_terminal_inner(query, timeout, false)
137 }
138
139 /// CSI-terminated variant of [`query_terminal`] for the sixel probe
140 /// (`tui::mark`): a primary-DA reply ends at its alphabetic final byte
141 /// (`c`), which is neither BEL nor `ESC \`, so the plain reader would keep
142 /// swallowing input — including the user's own typed-ahead keystrokes —
143 /// until its byte cap. Stops after the final byte of a reply that opened
144 /// with `ESC [` and keeps the same raw-mode caveat.
145 #[cfg(unix)]
146 pub fn query_terminal_csi(query: &[u8], timeout: std::time::Duration) -> Option<Vec<u8>> {
147 query_terminal_inner(query, timeout, true)
148 }
149
150 /// Largest reply any of the three probes can produce. Past this the answer
151 /// is not one of ours.
152 const MAX_REPLY_BYTES: usize = 128;
153
154 // ---------------------------------------------------------------------------
155 // Type-ahead carried across the probe window (#5925).
156 //
157 // The probe readers below are the only readers of the tty between raw-mode
158 // entry and the input pump, so anything the user typed at launch arrives in
159 // the same stream as the replies. The reader keeps its reply and parks every
160 // other byte here; `tui::startup_input` drains this and replays it into the
161 // composer. The buffers live in this module rather than with the replay
162 // logic because `src/palette/` is `#[path]`-included by test harnesses that
163 // do not compile the `tui` module tree.
164 // ---------------------------------------------------------------------------
165
166 /// Upper bound on carried type-ahead. A terminal that answers a probe does
167 /// so in well under a millisecond, so this only ever holds a line or two;
168 /// the cap stops a wedged tty from growing the buffer without limit.
169 pub const MAX_CARRIED_BYTES: usize = 4096;
170
171 /// Bytes a probe consumed that were not part of its reply.
172 static CARRIED_TYPE_AHEAD: std::sync::Mutex<Vec<u8>> = std::sync::Mutex::new(Vec::new());
173 /// Bytes a probe consumed that cannot be replayed as keystrokes.
174 static CONSUMED_UNREPLAYABLE: std::sync::Mutex<Vec<u8>> = std::sync::Mutex::new(Vec::new());
175
176 /// Park non-reply bytes for the event loop to replay.
177 #[cfg_attr(not(unix), allow(dead_code))]
178 pub fn carry_typed_ahead(bytes: &[u8]) {
179 if bytes.is_empty() {
180 return;
181 }
182 let Ok(mut carried) = CARRIED_TYPE_AHEAD.lock() else {
183 note_consumed_unreplayable(bytes);
184 return;
185 };
186 let room = MAX_CARRIED_BYTES.saturating_sub(carried.len());
187 let (keep, overflow) = bytes.split_at(room.min(bytes.len()));
188 carried.extend_from_slice(keep);
189 drop(carried);
190 note_consumed_unreplayable(overflow);
191 }
192
193 /// Record bytes startup consumed and cannot hand back.
194 pub fn note_consumed_unreplayable(bytes: &[u8]) {
195 if bytes.is_empty() {
196 return;
197 }
198 if let Ok(mut dropped) = CONSUMED_UNREPLAYABLE.lock() {
199 let room = MAX_CARRIED_BYTES.saturating_sub(dropped.len());
200 dropped.extend_from_slice(&bytes[..room.min(bytes.len())]);
201 }
202 }
203
204 /// Take everything parked by [`carry_typed_ahead`].
205 pub fn take_carried_type_ahead() -> Vec<u8> {
206 CARRIED_TYPE_AHEAD
207 .lock()
208 .map(|mut carried| std::mem::take(&mut *carried))
209 .unwrap_or_default()
210 }
211
212 /// Take everything recorded by [`note_consumed_unreplayable`].
213 pub fn take_consumed_unreplayable() -> Vec<u8> {
214 CONSUMED_UNREPLAYABLE
215 .lock()
216 .map(|mut dropped| std::mem::take(&mut *dropped))
217 .unwrap_or_default()
218 }
219
220 /// What the caller should do after feeding one byte to [`ProbeSplit`].
221 #[cfg_attr(not(unix), allow(dead_code))]
222 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
223 pub(crate) enum ProbeStep {
224 /// Keep reading.
225 Continue,
226 /// The reply is complete.
227 Done,
228 /// The reply ended with the `ESC` of an `ESC \` string terminator: read
229 /// one more byte and give it to [`ProbeSplit::finish_string_terminator`].
230 AwaitStringTerminator,
231 /// Carried type-ahead hit its cap; stop reading.
232 Overflow,
233 }
234
235 /// Splits one probe stream into the terminal's reply and the user's
236 /// type-ahead (#5925).
237 ///
238 /// Pure and byte-driven so the split — the actual defect in #5925, where
239 /// everything that was not the reply was thrown away — is testable without a
240 /// terminal. The reply always opens with the same two bytes the query did
241 /// (`ESC ]` for OSC 11, `ESC _` for the kitty graphics query, `ESC [` for the
242 /// sixel primary-DA query); anything before that introducer is input the user
243 /// typed, and an `ESC` that does not go on to match it is theirs too.
244 #[cfg_attr(not(unix), allow(dead_code))]
245 #[derive(Debug)]
246 pub(crate) struct ProbeSplit<'a> {
247 introducer: &'a [u8],
248 stop_at_csi_final: bool,
249 carried: Vec<u8>,
250 /// A partial introducer match, still undecided between reply and input.
251 undecided: Vec<u8>,
252 reply: Vec<u8>,
253 in_reply: bool,
254 }
255
256 #[cfg_attr(not(unix), allow(dead_code))]
257 impl<'a> ProbeSplit<'a> {
258 /// `query` is the sequence just written; its first two bytes are the
259 /// introducer the reply will open with.
260 pub(crate) fn for_query(query: &'a [u8], stop_at_csi_final: bool) -> Self {
261 Self {
262 introducer: if query.len() >= 2 && query[0] == 0x1b {
263 &query[..2]
264 } else {
265 &[]
266 },
267 stop_at_csi_final,
268 carried: Vec::new(),
269 undecided: Vec::new(),
270 reply: Vec::new(),
271 in_reply: false,
272 }
273 }
274
275 pub(crate) fn feed(&mut self, byte: u8) -> ProbeStep {
276 if !self.in_reply {
277 self.feed_before_reply(byte);
278 if self.carried.len() >= MAX_CARRIED_BYTES {
279 return ProbeStep::Overflow;
280 }
281 return ProbeStep::Continue;
282 }
283 // BEL, or the ESC of an `ESC \` string terminator, ends the reply.
284 if byte == 0x07 {
285 return ProbeStep::Done;
286 }
287 if byte == 0x1b {
288 return ProbeStep::AwaitStringTerminator;
289 }
290 self.reply.push(byte);
291 // A CSI reply (`ESC [` …) ends at its first final byte (`@..=~`):
292 // keep the final and stop, so a DA answer never eats past itself.
293 if self.stop_at_csi_final
294 && self.reply.len() >= 3
295 && self.reply[0] == 0x1b
296 && self.reply[1] == b'['
297 && (0x40..=0x7e).contains(&byte)
298 {
299 return ProbeStep::Done;
300 }
301 if self.reply.len() >= MAX_REPLY_BYTES {
302 return ProbeStep::Overflow;
303 }
304 ProbeStep::Continue
305 }
306
307 fn feed_before_reply(&mut self, byte: u8) {
308 if self.undecided.is_empty() {
309 if byte == 0x1b && !self.introducer.is_empty() {
310 self.undecided.push(byte);
311 } else {
312 self.carried.push(byte);
313 }
314 return;
315 }
316 self.undecided.push(byte);
317 if self.introducer.starts_with(&self.undecided) {
318 if self.undecided.len() == self.introducer.len() {
319 self.in_reply = true;
320 self.reply = std::mem::take(&mut self.undecided);
321 }
322 return;
323 }
324 // Not our reply after all — an `Esc` keypress, or an escape sequence
325 // from some other source. Everything held is the user's, except a
326 // fresh `ESC` which may still open the reply we are waiting for.
327 let restarts = byte == 0x1b;
328 if restarts {
329 self.undecided.pop();
330 }
331 self.carried.append(&mut self.undecided);
332 if restarts {
333 self.undecided.push(byte);
334 }
335 }
336
337 /// The byte read after an [`ProbeStep::AwaitStringTerminator`]. The `\`
338 /// of `ESC \` belongs to the reply; anything else is the user's next
339 /// keystroke and must not vanish with the terminator.
340 pub(crate) fn finish_string_terminator(&mut self, byte: u8) {
341 if byte != b'\\' {
342 self.carried.push(byte);
343 }
344 }
345
346 /// Consume the split: `(reply, carried type-ahead)`. An undecided
347 /// introducer is input the terminal never claimed.
348 pub(crate) fn finish(mut self) -> (Vec<u8>, Vec<u8>) {
349 self.carried.append(&mut self.undecided);
350 (self.reply, self.carried)
351 }
352 }
353
354 /// Read a probe reply off stdin without eating the user's type-ahead.
355 ///
356 /// The reply always opens with the same two bytes the query did (`ESC ]` for
357 /// OSC 11, `ESC _` for the kitty graphics query, `ESC [` for the sixel
358 /// primary-DA query), so every byte before that introducer — and the one
359 /// lookahead byte after an `ESC` that turned out not to open `ESC \` — is
360 /// input the user typed, not terminal chatter. Those bytes are carried to
361 /// [`carry_typed_ahead`] for replay instead of being dropped on the
362 /// floor (#5925). Bytes this reader consumed but cannot hand back (a reply
363 /// the terminal never terminated) are recorded as dropped so the startup
364 /// receipt names them.
365 #[cfg(unix)]
366 fn query_terminal_inner(
367 query: &[u8],
368 timeout: std::time::Duration,
369 stop_at_csi_final: bool,
370 ) -> Option<Vec<u8>> {
371 use std::io::Write;
372 use std::os::fd::AsRawFd;
373
374 let stdin = std::io::stdin();
375 let stdout = std::io::stdout();
376 let in_fd = stdin.as_raw_fd();
377 let out_fd = stdout.as_raw_fd();
378
379 // SAFETY: `isatty` only inspects the descriptor; both fds are owned by the
380 // std handles held above for the duration of the call.
381 let both_tty = unsafe { libc::isatty(in_fd) == 1 && libc::isatty(out_fd) == 1 };
382 if !both_tty {
383 return None;
384 }
385
386 {
387 let mut out = stdout.lock();
388 out.write_all(query).ok()?;
389 out.flush().ok()?;
390 }
391
392 let (answered, reply, carried) = read_terminal_reply(in_fd, query, timeout, stop_at_csi_final);
393 carry_typed_ahead(&carried);
394 if !answered {
395 // An incomplete control reply is not safe to replay as typing.
396 note_consumed_unreplayable(&reply);
397 return None;
398 }
399 Some(reply)
400 }
401
402 #[cfg(unix)]
403 fn read_terminal_reply(
404 in_fd: std::os::fd::RawFd,
405 query: &[u8],
406 timeout: std::time::Duration,
407 stop_at_csi_final: bool,
408 ) -> (bool, Vec<u8>, Vec<u8>) {
409 use std::time::Instant;
410
411 let deadline = Instant::now() + timeout;
412 let mut split = ProbeSplit::for_query(query, stop_at_csi_final);
413 let answered = loop {
414 let Some(byte) = read_terminal_byte(in_fd, deadline) else {
415 break false;
416 };
417 match split.feed(byte) {
418 ProbeStep::Continue => {}
419 ProbeStep::Done => break true,
420 ProbeStep::Overflow => break false,
421 ProbeStep::AwaitStringTerminator => {
422 // Consume the `\` of an `ESC \` terminator so it cannot
423 // surface later as a keypress once the event loop owns
424 // stdin. Anything else is the user's next keystroke.
425 let terminator_deadline = Instant::now() + std::time::Duration::from_millis(5);
426 if let Some(byte) = read_terminal_byte(in_fd, terminator_deadline) {
427 split.finish_string_terminator(byte);
428 }
429 break true;
430 }
431 }
432 };
433
434 let (reply, carried) = split.finish();
435 (answered, reply, carried)
436 }
437
438 /// Poll and read the same unbuffered descriptor. `StdinLock` reads ahead into
439 /// Rust's shared buffer: polling the tty afterward misses those bytes, and
440 /// crossterm's later fd reader cannot recover them either.
441 #[cfg(unix)]
442 fn read_terminal_byte(fd: std::os::fd::RawFd, deadline: std::time::Instant) -> Option<u8> {
443 loop {
444 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
445 if remaining.is_zero() || !wait_readable(fd, remaining) {
446 return None;
447 }
448 let mut byte = 0u8;
449 // SAFETY: the caller owns the open fd throughout the startup probe;
450 // `byte` is writable for exactly the single byte requested. The input
451 // pump has not started, so there is no competing reader.
452 let count = unsafe { libc::read(fd, std::ptr::addr_of_mut!(byte).cast(), 1) };
453 if count == 1 {
454 return Some(byte);
455 }
456 if count != -1 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
457 {
458 return None;
459 }
460 }
461 }
462
463 #[cfg(all(test, unix))]
464 #[path = "osc11_tests.rs"]
465 mod tests;
466
467 /// Block until `fd` has data or `timeout` elapses. `true` means readable.
468 #[cfg(unix)]
469 fn wait_readable(fd: std::os::fd::RawFd, timeout: std::time::Duration) -> bool {
470 let mut pollfd = libc::pollfd {
471 fd,
472 events: libc::POLLIN,
473 revents: 0,
474 };
475 let millis = i32::try_from(timeout.as_millis())
476 .unwrap_or(i32::MAX)
477 .max(1);
478 // SAFETY: `pollfd` is a live, correctly-initialized single-element array
479 // and the count matches.
480 let rc = unsafe { libc::poll(std::ptr::addr_of_mut!(pollfd), 1, millis) };
481 rc > 0 && (pollfd.revents & libc::POLLIN) != 0
482 }
483
484 /// Non-Unix platforms have no portable way to read a raw OSC reply back off
485 /// the console handle, so detection falls through to the environment-based
486 /// sources. Callers treat `None` as "no evidence", never as "dark".
487 #[cfg(not(unix))]
488 pub fn query_terminal(_query: &[u8], _timeout: std::time::Duration) -> Option<Vec<u8>> {
489 None
490 }
491
492 /// Non-Unix twin of [`query_terminal_csi`]: no console to ask, no evidence.
493 #[cfg(not(unix))]
494 pub fn query_terminal_csi(_query: &[u8], _timeout: std::time::Duration) -> Option<Vec<u8>> {
495 None
496 }
497
497 lines RUST