1use std::{env, fmt, io};
4
5use crate::{
6 config::ThemePreference,
7 theme::{Theme, ThemeKind},
8};
9
10#[cfg(any(unix, windows))]
11const OSC11_QUERY: &[u8] = b"\x1b]11;?\x1b\\";
12#[cfg(any(unix, windows))]
13const OSC11_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(75);
14#[cfg(any(unix, windows))]
15const OSC11_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(20);
16#[cfg(any(unix, windows))]
17const OSC11_RESPONSE_CAP: usize = 512;
18#[cfg(any(unix, windows))]
19const OSC11_DRAIN_CAP: usize = 1024;
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum ThemeDetectionSource {
23 Explicit,
24 Osc11,
25 ColorFgBg,
26 Fallback,
27}
28
29impl fmt::Display for ThemeDetectionSource {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter.write_str(match self {
32 Self::Explicit => "explicit",
33 Self::Osc11 => "osc11",
34 Self::ColorFgBg => "colorfgbg",
35 Self::Fallback => "fallback",
36 })
37 }
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct ThemeDetection {
42 pub kind: ThemeKind,
43 pub source: ThemeDetectionSource,
44}
45
46trait Osc11Query {
47 fn is_eligible(&self) -> bool;
48 fn query(&mut self) -> Option<Vec<u8>>;
49}
50
51struct SystemOsc11Query;
52
53impl Osc11Query for SystemOsc11Query {
54 fn is_eligible(&self) -> bool {
55 terminal_is_eligible()
56 }
57
58 fn query(&mut self) -> Option<Vec<u8>> {
59 query_osc11()
60 }
61}
62
63pub fn detect_startup_theme(preference: Option<ThemePreference>) -> ThemeDetection {
66 let cookie_theme = env::var("COOKIE_THEME").ok();
67 #[cfg(unix)]
68 let colorfgbg = env::var("COLORFGBG").ok();
69 #[cfg(windows)]
70 let colorfgbg: Option<String> = None;
71 detect_with_query(
72 preference,
73 cookie_theme.as_deref(),
74 colorfgbg.as_deref(),
75 &mut SystemOsc11Query,
76 )
77}
78
79pub(crate) fn theme_without_terminal_detection(preference: Option<ThemePreference>) -> Theme {
81 let cookie_theme = env::var("COOKIE_THEME").ok();
82 let detection = detect_with_query(preference, cookie_theme.as_deref(), None, &mut NoOsc11Query);
83 Theme::with_kind_from_env(detection.kind)
84}
85
86struct NoOsc11Query;
87
88impl Osc11Query for NoOsc11Query {
89 fn is_eligible(&self) -> bool {
90 false
91 }
92
93 fn query(&mut self) -> Option<Vec<u8>> {
94 None
95 }
96}
97
98fn detect_with_query(
99 preference: Option<ThemePreference>,
100 cookie_theme: Option<&str>,
101 colorfgbg: Option<&str>,
102 query: &mut impl Osc11Query,
103) -> ThemeDetection {
104 match requested_theme(preference, cookie_theme) {
105 RequestedTheme::Explicit(kind) => ThemeDetection {
106 kind,
107 source: ThemeDetectionSource::Explicit,
108 },
109 RequestedTheme::Auto => {
110 let response = if query.is_eligible() {
111 query.query()
112 } else {
113 None
114 };
115 if let Some(kind) = response
116 .as_deref()
117 .and_then(parse_osc11_response)
118 .map(theme_for_rgb)
119 {
120 return ThemeDetection {
121 kind,
122 source: ThemeDetectionSource::Osc11,
123 };
124 }
125 if let Some(kind) = colorfgbg.and_then(parse_colorfgbg) {
126 return ThemeDetection {
127 kind,
128 source: ThemeDetectionSource::ColorFgBg,
129 };
130 }
131 ThemeDetection {
132 kind: platform_fallback_theme(),
133 source: ThemeDetectionSource::Fallback,
134 }
135 }
136 }
137}
138
139#[cfg(unix)]
140fn platform_fallback_theme() -> ThemeKind {
141 ThemeKind::Default
142}
143
144#[cfg(windows)]
145fn platform_fallback_theme() -> ThemeKind {
146 ThemeKind::Dark
147}
148
149enum RequestedTheme {
150 Auto,
151 Explicit(ThemeKind),
152}
153
154fn requested_theme(
155 preference: Option<ThemePreference>,
156 cookie_theme: Option<&str>,
157) -> RequestedTheme {
158 if let Some(preference) = preference {
159 return preference
160 .explicit_kind()
161 .map_or(RequestedTheme::Auto, RequestedTheme::Explicit);
162 }
163 let cookie_theme = cookie_theme.map(str::to_ascii_lowercase);
164 match cookie_theme.as_deref() {
165 None | Some("auto") => RequestedTheme::Auto,
166 Some("dark" | "dark-roast" | "darkroast") => RequestedTheme::Explicit(ThemeKind::Dark),
167 Some("mono" | "monochrome") => RequestedTheme::Explicit(ThemeKind::Mono),
168 Some("high-contrast" | "high_contrast" | "contrast") => {
169 RequestedTheme::Explicit(ThemeKind::HighContrast)
170 }
171 Some("default") => RequestedTheme::Explicit(ThemeKind::Default),
172 Some(_) => RequestedTheme::Explicit(ThemeKind::Default),
174 }
175}
176
177fn parse_osc11_response(response: &[u8]) -> Option<(f64, f64, f64)> {
178 let response = std::str::from_utf8(response).ok()?;
179 let channels = response.get(response.find("rgb:")? + 4..)?;
180 let mut channels = channels.split('/');
181 let (red, red_digits) = parse_rgb_component(channels.next()?)?;
182 let (green, green_digits) = parse_rgb_component(channels.next()?)?;
183 let (blue, blue_digits) = parse_rgb_component(channels.next()?)?;
184 if red_digits != green_digits || red_digits != blue_digits {
185 return None;
186 }
187 Some((red, green, blue))
188}
189
190fn parse_rgb_component(component: &str) -> Option<(f64, usize)> {
191 let digits = component
192 .as_bytes()
193 .iter()
194 .take_while(|byte| byte.is_ascii_hexdigit())
195 .count();
196 if digits != 2 && digits != 4 {
197 return None;
198 }
199 let value = u16::from_str_radix(component.get(..digits)?, 16).ok()?;
200 Some((
201 f64::from(value) / if digits == 2 { 255.0 } else { 65_535.0 },
202 digits,
203 ))
204}
205
206fn theme_for_rgb(rgb: (f64, f64, f64)) -> ThemeKind {
207 if relative_luminance(rgb) < 0.5 {
208 ThemeKind::Dark
209 } else {
210 ThemeKind::Default
211 }
212}
213
214fn relative_luminance((red, green, blue): (f64, f64, f64)) -> f64 {
215 fn linearize(channel: f64) -> f64 {
216 if channel <= 0.04045 {
217 channel / 12.92
218 } else {
219 ((channel + 0.055) / 1.055).powf(2.4)
220 }
221 }
222 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue)
223}
224
225fn parse_colorfgbg(value: &str) -> Option<ThemeKind> {
226 let mut fields = value.split(';').map(str::trim);
227 let foreground = fields.next()?;
228 let second = fields.next()?;
229 let third = fields.next();
230 if fields.next().is_some() || foreground.parse::<u8>().ok()? > 15 {
231 return None;
232 }
233 let background = match third {
234 None => second,
235 Some(background) if second.eq_ignore_ascii_case("default") => background,
236 Some(_) => return None,
237 };
238 if background.eq_ignore_ascii_case("default") {
239 return None;
240 }
241 match background.parse::<u8>().ok()? {
242 0..=6 | 8 => Some(ThemeKind::Dark),
243 7 | 9..=15 => Some(ThemeKind::Default),
244 _ => None,
245 }
246}
247
248fn terminal_is_eligible() -> bool {
249 use std::io::IsTerminal as _;
250
251 io::stdout().is_terminal() && io::stdin().is_terminal()
252}
253
254#[cfg(any(unix, windows))]
255fn query_osc11_io(
256 input: &mut impl io::Read,
257 output: &mut impl io::Write,
258 query_timeout: std::time::Duration,
259 drain_timeout: std::time::Duration,
260) -> Option<Vec<u8>> {
261 use std::{thread, time::Instant};
262
263 output.write_all(OSC11_QUERY).ok()?;
264 output.flush().ok()?;
265
266 let deadline = Instant::now() + query_timeout;
267 let mut response = Vec::with_capacity(64);
268 let mut buffer = [0_u8; 128];
269 while Instant::now() < deadline && response.len() < OSC11_RESPONSE_CAP {
270 match input.read(&mut buffer) {
271 Ok(0) => break,
272 Ok(read) => {
273 response.extend_from_slice(&buffer[..read]);
274 if response.contains(&b'\x07')
275 || response.windows(2).any(|window| window == b"\x1b\\")
276 {
277 break;
278 }
279 }
280 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
281 thread::sleep(std::time::Duration::from_millis(2));
282 }
283 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
284 Err(_) => break,
285 }
286 }
287
288 let drain_deadline = Instant::now() + drain_timeout;
292 let mut drained = 0;
293 while Instant::now() < drain_deadline && drained < OSC11_DRAIN_CAP {
294 match input.read(&mut buffer) {
295 Ok(0) => break,
296 Ok(read) => drained += read,
297 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
298 thread::sleep(std::time::Duration::from_millis(2));
299 }
300 Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
301 Err(_) => break,
302 }
303 }
304
305 (!response.is_empty()).then_some(response)
306}
307
308#[cfg(unix)]
309fn query_osc11() -> Option<Vec<u8>> {
310 use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
311 use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
312
313 struct RawModeGuard;
314 impl Drop for RawModeGuard {
315 fn drop(&mut self) {
316 let _ = disable_raw_mode();
317 }
318 }
319
320 enable_raw_mode().ok()?;
321 let _raw_mode = RawModeGuard;
322 let stdin = io::stdin();
323 let original_flags = fcntl_getfl(&stdin).ok()?;
324 fcntl_setfl(&stdin, original_flags | OFlags::NONBLOCK).ok()?;
325 struct NonblockingGuard<'a> {
326 input: &'a io::Stdin,
327 original_flags: OFlags,
328 }
329 impl Drop for NonblockingGuard<'_> {
330 fn drop(&mut self) {
331 let _ = fcntl_setfl(self.input, self.original_flags);
332 }
333 }
334 let _nonblocking = NonblockingGuard {
335 input: &stdin,
336 original_flags,
337 };
338 let mut input = stdin.lock();
339 let mut output = io::stdout().lock();
340 query_osc11_io(&mut input, &mut output, OSC11_TIMEOUT, OSC11_DRAIN_TIMEOUT)
341}
342
343#[cfg(windows)]
344fn query_osc11() -> Option<Vec<u8>> {
345 use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
346 use windows_sys::Win32::{
347 Foundation::{INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
348 Storage::FileSystem::ReadFile,
349 System::{
350 Console::{GetStdHandle, STD_INPUT_HANDLE},
351 Threading::WaitForSingleObject,
352 },
353 };
354
355 struct RawModeGuard;
356 impl Drop for RawModeGuard {
357 fn drop(&mut self) {
358 let _ = disable_raw_mode();
359 }
360 }
361
362 struct PolledStdin(windows_sys::Win32::Foundation::HANDLE);
363 impl io::Read for PolledStdin {
364 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
365 let wait = unsafe { WaitForSingleObject(self.0, 0) };
366 match wait {
367 WAIT_TIMEOUT => return Err(io::ErrorKind::WouldBlock.into()),
368 WAIT_FAILED => return Err(io::Error::last_os_error()),
369 WAIT_OBJECT_0 => {}
370 _ => return Err(io::Error::other("unexpected stdin wait result")),
371 }
372 let length = u32::try_from(buffer.len()).unwrap_or(u32::MAX);
373 let mut read = 0;
374 let success = unsafe {
375 ReadFile(
376 self.0,
377 buffer.as_mut_ptr(),
378 length,
379 &mut read,
380 std::ptr::null_mut(),
381 )
382 };
383 if success == 0 {
384 Err(io::Error::last_os_error())
385 } else {
386 Ok(read as usize)
387 }
388 }
389 }
390
391 enable_raw_mode().ok()?;
392 let _raw_mode = RawModeGuard;
393 let input_handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
394 if input_handle.is_null() || input_handle == INVALID_HANDLE_VALUE {
395 return None;
396 }
397 let mut input = PolledStdin(input_handle);
398 let mut output = io::stdout().lock();
399 query_osc11_io(&mut input, &mut output, OSC11_TIMEOUT, OSC11_DRAIN_TIMEOUT)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 struct CannedQuery {
407 response: Option<Vec<u8>>,
408 calls: usize,
409 eligible: bool,
410 }
411
412 impl Default for CannedQuery {
413 fn default() -> Self {
414 Self {
415 response: None,
416 calls: 0,
417 eligible: true,
418 }
419 }
420 }
421
422 impl Osc11Query for CannedQuery {
423 fn is_eligible(&self) -> bool {
424 self.eligible
425 }
426
427 fn query(&mut self) -> Option<Vec<u8>> {
428 self.calls += 1;
429 self.response.take()
430 }
431 }
432
433 #[test]
434 fn parses_osc11_rgb_responses() {
435 assert_eq!(
436 parse_osc11_response(b"\x1b]11;rgb:ffff/8080/0000\x1b\\"),
437 Some((1.0, 32_896.0 / 65_535.0, 0.0))
438 );
439 assert_eq!(
440 parse_osc11_response(b"\x1b]11;rgb:ff/80/00\x07"),
441 Some((1.0, 128.0 / 255.0, 0.0))
442 );
443 assert_eq!(parse_osc11_response(b"garbage"), None);
444 assert_eq!(parse_osc11_response(b""), None);
445 assert_eq!(parse_osc11_response(b"rgb:fff/0000/0000"), None);
446 assert_eq!(parse_osc11_response(b"rgb:ff/0000/0000"), None);
447 }
448
449 #[test]
450 fn colorfgbg_classifies_documented_indices() {
451 for index in [0, 1, 2, 3, 4, 5, 6, 8] {
452 assert_eq!(
453 parse_colorfgbg(&format!("15;{index}")),
454 Some(ThemeKind::Dark)
455 );
456 }
457 for index in [7, 9, 10, 11, 12, 13, 14, 15] {
458 assert_eq!(
459 parse_colorfgbg(&format!("0;default;{index}")),
460 Some(ThemeKind::Default)
461 );
462 }
463 for value in [
464 "0",
465 "0;default",
466 "default",
467 "0;16",
468 "0;nope",
469 "0;default;1;7",
470 "0;wrong;7",
471 "x;7",
472 "16;7",
473 "",
474 ] {
475 assert_eq!(parse_colorfgbg(value), None, "{value:?}");
476 }
477 }
478
479 #[cfg(unix)]
480 #[test]
481 fn delayed_osc_reply_is_drained_before_event_handling() {
482 use std::{
483 io::{Read as _, Write as _},
484 os::unix::net::UnixStream,
485 thread,
486 time::Duration,
487 };
488
489 let (mut app_input, mut terminal) = UnixStream::pair().expect("terminal pair");
490 app_input
491 .set_nonblocking(true)
492 .expect("nonblocking app input");
493 let mut query_output = app_input.try_clone().expect("query output");
494 let terminal_reply = thread::spawn(move || {
495 let mut query = [0_u8; OSC11_QUERY.len()];
496 terminal.read_exact(&mut query).expect("read query");
497 assert_eq!(&query, OSC11_QUERY);
498 thread::sleep(Duration::from_millis(15));
499 terminal
500 .write_all(b"\x1b]11;rgb:0000/0000/0000\x1b\\")
501 .expect("write delayed reply");
502 thread::sleep(Duration::from_millis(80));
505 });
506
507 let _ = query_osc11_io(
508 &mut app_input,
509 &mut query_output,
510 Duration::from_millis(10),
511 OSC11_DRAIN_TIMEOUT,
512 );
513 let mut event_bytes = [0_u8; 64];
514 let read = app_input.read(&mut event_bytes);
515 assert!(
516 matches!(read, Err(ref error) if error.kind() == io::ErrorKind::WouldBlock),
517 "delayed OSC bytes reached event handling: {read:?}"
518 );
519 terminal_reply.join().expect("terminal reply thread");
520 }
521
522 #[test]
523 fn luminance_threshold_selects_dark_below_half() {
524 assert_eq!(theme_for_rgb((0.0, 0.0, 0.0)), ThemeKind::Dark);
525 assert_eq!(theme_for_rgb((1.0, 1.0, 1.0)), ThemeKind::Default);
526 assert_eq!(theme_for_rgb((0.735, 0.735, 0.735)), ThemeKind::Dark);
527 assert_eq!(theme_for_rgb((0.736, 0.736, 0.736)), ThemeKind::Default);
528 }
529
530 #[test]
531 fn explicit_theme_beats_detection_and_reports_source() {
532 let mut query = CannedQuery {
533 response: Some(b"rgb:0000/0000/0000".to_vec()),
534 ..CannedQuery::default()
535 };
536 let detection = detect_with_query(
537 Some(ThemePreference::Default),
538 Some("dark"),
539 Some("0;0"),
540 &mut query,
541 );
542 assert_eq!(
543 detection,
544 ThemeDetection {
545 kind: ThemeKind::Default,
546 source: ThemeDetectionSource::Explicit,
547 }
548 );
549 assert_eq!(query.calls, 0);
550 assert_eq!(ThemeDetectionSource::Explicit.to_string(), "explicit");
551
552 let mut cookie_query = CannedQuery {
553 response: Some(b"rgb:ffff/ffff/ffff".to_vec()),
554 ..CannedQuery::default()
555 };
556 assert_eq!(
557 detect_with_query(None, Some("dark"), Some("0;15"), &mut cookie_query),
558 ThemeDetection {
559 kind: ThemeKind::Dark,
560 source: ThemeDetectionSource::Explicit,
561 }
562 );
563 assert_eq!(cookie_query.calls, 0);
564 }
565
566 #[test]
567 fn non_tty_skips_the_query_entirely() {
568 let mut query = CannedQuery {
569 response: Some(b"rgb:0000/0000/0000".to_vec()),
570 eligible: false,
571 ..CannedQuery::default()
572 };
573 assert_eq!(
574 detect_with_query(None, None, Some("0;15"), &mut query),
575 ThemeDetection {
576 kind: ThemeKind::Default,
577 source: ThemeDetectionSource::ColorFgBg,
578 }
579 );
580 assert_eq!(query.calls, 0, "ineligible terminal was queried");
581 }
582
583 #[test]
584 fn auto_uses_osc_then_colorfgbg_then_fallback() {
585 let mut osc = CannedQuery {
586 response: Some(b"\x1b]11;rgb:0000/0000/0000\x1b\\".to_vec()),
587 ..CannedQuery::default()
588 };
589 assert_eq!(
590 detect_with_query(None, None, Some("0;15"), &mut osc),
591 ThemeDetection {
592 kind: ThemeKind::Dark,
593 source: ThemeDetectionSource::Osc11,
594 }
595 );
596
597 let mut absent = CannedQuery::default();
598 assert_eq!(
599 detect_with_query(
600 Some(ThemePreference::Auto),
601 Some("dark"),
602 Some("0;15"),
603 &mut absent
604 ),
605 ThemeDetection {
606 kind: ThemeKind::Default,
607 source: ThemeDetectionSource::ColorFgBg,
608 }
609 );
610 let mut invalid = CannedQuery {
611 response: Some(b"not an OSC response".to_vec()),
612 ..CannedQuery::default()
613 };
614 assert_eq!(
615 detect_with_query(None, None, Some("0;0"), &mut invalid),
616 ThemeDetection {
617 kind: ThemeKind::Dark,
618 source: ThemeDetectionSource::ColorFgBg,
619 }
620 );
621 assert_eq!(
622 detect_with_query(None, Some("auto"), Some("default"), &mut absent),
623 ThemeDetection {
624 kind: platform_fallback_theme(),
625 source: ThemeDetectionSource::Fallback,
626 }
627 );
628 }
629
630 #[test]
631 fn no_color_coercion_happens_after_detection() {
632 let mut query = CannedQuery {
633 response: Some(b"rgb:0000/0000/0000".to_vec()),
634 ..CannedQuery::default()
635 };
636 let detection = detect_with_query(None, None, None, &mut query);
637 assert_eq!(detection.kind, ThemeKind::Dark);
638 let theme =
639 Theme::from_kind_environment(detection.kind, true, "xterm-256color", "truecolor");
640 assert_eq!(theme.key().kind, ThemeKind::Mono);
641 }
642
643 #[cfg(windows)]
644 #[test]
645 fn unavailable_automatic_detection_falls_back_to_dark() {
646 let mut query = CannedQuery::default();
647 assert_eq!(
648 detect_with_query(None, None, None, &mut query),
649 ThemeDetection {
650 kind: ThemeKind::Dark,
651 source: ThemeDetectionSource::Fallback,
652 }
653 );
654 }
655}