use std::io::IsTerminal;
use std::time::Duration;
pub const QUERY_BG: &str = "\x1b]11;?\x07";
pub const DARK_THRESHOLD: f32 = 0.5;
pub const BUDGET: Duration = if cfg!(windows) {
Duration::from_millis(150)
} else {
Duration::from_millis(500)
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Background {
Dark,
Light,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvOverride {
Force(Background),
Off,
}
pub fn env_override() -> Option<EnvOverride> {
let raw = std::env::var("MINDFORK_TERMINAL_BG").ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"dark" => Some(EnvOverride::Force(Background::Dark)),
"light" => Some(EnvOverride::Force(Background::Light)),
"off" | "none" => Some(EnvOverride::Off),
other => {
tracing::warn!(
value = %other,
"MINDFORK_TERMINAL_BG is not one of dark/light/off — ignoring it"
);
None
}
}
}
pub fn parse_reply(raw: &[u8]) -> Option<Rgb> {
let text = std::str::from_utf8(raw).ok()?;
if let Some(at) = text.find("rgb:") {
let body = &text[at + 4..];
let mut parts = body.split('/');
let r = scale(parts.next()?)?;
let g = scale(parts.next()?)?;
let b = scale(trim_terminator(parts.next()?))?;
return Some(Rgb { r, g, b });
}
if let Some(at) = text.find('#') {
let hex = trim_terminator(&text[at + 1..]);
if hex.len() == 6 && hex.bytes().all(|c| c.is_ascii_hexdigit()) {
let v = u32::from_str_radix(hex, 16).ok()?;
return Some(Rgb {
r: ((v >> 16) & 0xff) as u8,
g: ((v >> 8) & 0xff) as u8,
b: (v & 0xff) as u8,
});
}
}
None
}
fn trim_terminator(s: &str) -> &str {
let end = s
.find(['\x07', '\x1b'])
.unwrap_or_else(|| s.trim_end().len().min(s.len()));
&s[..end]
}
fn scale(part: &str) -> Option<u8> {
let part = part.trim();
if part.is_empty() || part.len() > 4 || !part.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(part, 16).ok()?;
let max = (1u32 << (part.len() * 4)) - 1;
Some(((value * 255 + max / 2) / max) as u8)
}
pub fn relative_luminance(rgb: Rgb) -> f32 {
fn channel(c: u8) -> f32 {
let c = f32::from(c) / 255.0;
if c <= 0.040_45 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b)
}
pub fn classify(rgb: Rgb) -> Background {
if relative_luminance(rgb) < DARK_THRESHOLD {
Background::Dark
} else {
Background::Light
}
}
const MAX_REPLY: usize = 128;
fn read_reply(mut next: impl FnMut() -> Option<u8>) -> Option<Rgb> {
if next()? != 0x1b {
return None;
}
if next()? != b']' {
return None;
}
let mut body = vec![0x1b, b']'];
loop {
let b = next()?;
body.push(b);
match b {
0x07 => break, 0x1b => {
let _ = next();
break;
}
_ if body.len() > MAX_REPLY => return None,
_ => {}
}
}
parse_reply(&body)
}
pub struct Pending {
#[cfg(unix)]
armed: bool,
#[cfg(unix)]
started: std::time::Instant,
#[cfg(windows)]
answer: Option<Background>,
}
pub fn begin() -> Option<Pending> {
match env_override() {
Some(EnvOverride::Off) => {
tracing::debug!("terminal background: query disabled by MINDFORK_TERMINAL_BG");
return None;
}
Some(EnvOverride::Force(_)) => return None, None => {}
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
tracing::debug!("terminal background: not a terminal on both ends — not asking");
return None;
}
begin_impl()
}
#[cfg(unix)]
fn begin_impl() -> Option<Pending> {
use std::io::Write;
if crossterm::terminal::enable_raw_mode().is_err() {
return None;
}
let mut out = std::io::stdout();
if out.write_all(QUERY_BG.as_bytes()).is_err() || out.flush().is_err() {
let _ = crossterm::terminal::disable_raw_mode();
return None;
}
Some(Pending {
armed: true,
started: std::time::Instant::now(),
})
}
#[cfg(windows)]
fn begin_impl() -> Option<Pending> {
Some(Pending {
answer: win::exchange(BUDGET),
})
}
impl Pending {
#[must_use]
pub fn harvest(mut self) -> Option<Background> {
#[cfg(unix)]
{
self.armed = false; let deadline = self.started + BUDGET;
let Some(rgb) = read_reply(|| unix::read_byte(deadline)) else {
tracing::debug!(
budget_ms = BUDGET.as_millis(),
waited_ms = self.started.elapsed().as_millis(),
"terminal background: no reply — falling back to dark"
);
return None;
};
let bg = classify(rgb);
tracing::debug!(
r = rgb.r,
g = rgb.g,
b = rgb.b,
luminance = relative_luminance(rgb),
?bg,
"terminal background detected"
);
Some(bg)
}
#[cfg(windows)]
{
self.answer.take()
}
}
}
#[must_use]
pub fn resolve(pending: Option<Pending>) -> Option<Background> {
let (outcome, source) = match env_override() {
Some(EnvOverride::Force(bg)) => (Some(bg), "forced by MINDFORK_TERMINAL_BG"),
Some(EnvOverride::Off) => (None, "not asked — MINDFORK_TERMINAL_BG=off"),
None => match pending.and_then(Pending::harvest) {
Some(bg) => (Some(bg), "reported by the terminal"),
None => (None, "the terminal did not answer"),
},
};
tracing::info!(
background = ?outcome.unwrap_or(Background::Dark),
detected = outcome.is_some(),
source,
"terminal background resolved"
);
outcome
}
#[cfg(unix)]
impl Drop for Pending {
fn drop(&mut self) {
if self.armed {
let _ = crossterm::terminal::disable_raw_mode();
}
}
}
#[cfg(unix)]
mod unix {
use std::time::Instant;
pub(super) fn read_byte(deadline: Instant) -> Option<u8> {
let remaining = deadline.saturating_duration_since(Instant::now());
let mut pfd = libc::pollfd {
fd: libc::STDIN_FILENO,
events: libc::POLLIN,
revents: 0,
};
let ms = i32::try_from(remaining.as_millis()).unwrap_or(i32::MAX);
if unsafe { libc::poll(&mut pfd, 1, ms) } <= 0 {
return None;
}
let mut byte = 0u8;
let n = unsafe {
libc::read(
libc::STDIN_FILENO,
std::ptr::from_mut(&mut byte).cast::<libc::c_void>(),
1,
)
};
(n == 1).then_some(byte)
}
}
#[cfg(windows)]
mod win {
use std::io::Write;
use std::time::{Duration, Instant};
use windows_sys::Win32::Foundation::{HANDLE, WAIT_OBJECT_0};
use windows_sys::Win32::System::Console::{
CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT,
ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode,
GetStdHandle, INPUT_RECORD, KEY_EVENT, ReadConsoleInputW, STD_INPUT_HANDLE,
STD_OUTPUT_HANDLE,
};
use windows_sys::Win32::System::Threading::WaitForSingleObject;
use super::{Background, QUERY_BG, classify, relative_luminance};
pub(super) fn exchange(budget: Duration) -> Option<Background> {
let h_in = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
let h_out = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
let saved_in = get_mode(h_in)?;
let saved_out = get_mode(h_out)?;
set_mode(h_out, saved_out | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
let wanted = (saved_in & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT))
| ENABLE_VIRTUAL_TERMINAL_INPUT;
set_mode(h_in, wanted);
let vt_input = get_mode(h_in).is_some_and(|m| m & ENABLE_VIRTUAL_TERMINAL_INPUT != 0);
let result = if vt_input {
ask(budget)
} else {
tracing::debug!(
"terminal background: console refused ENABLE_VIRTUAL_TERMINAL_INPUT — no reply is possible"
);
None
};
set_mode(h_in, saved_in);
set_mode(h_out, saved_out);
result
}
fn ask(budget: Duration) -> Option<Background> {
let mut out = std::io::stdout();
out.write_all(QUERY_BG.as_bytes()).ok()?;
out.flush().ok()?;
let deadline = Instant::now() + budget;
let Some(rgb) = super::read_reply(|| read_byte(deadline)) else {
tracing::debug!(
budget_ms = budget.as_millis(),
"terminal background: no reply — falling back to dark"
);
return None;
};
let bg = classify(rgb);
tracing::debug!(
r = rgb.r,
g = rgb.g,
b = rgb.b,
luminance = relative_luminance(rgb),
?bg,
"terminal background detected"
);
Some(bg)
}
fn read_byte(deadline: Instant) -> Option<u8> {
let h_in = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let ms = u32::try_from(remaining.as_millis()).unwrap_or(u32::MAX);
if unsafe { WaitForSingleObject(h_in, ms) } != WAIT_OBJECT_0 {
return None;
}
let mut record: INPUT_RECORD = unsafe { std::mem::zeroed() };
let mut read = 0u32;
let ok = unsafe { ReadConsoleInputW(h_in, &mut record, 1, &mut read) };
if ok == 0 || read != 1 {
return None;
}
if record.EventType != KEY_EVENT as u16 {
continue;
}
let key = unsafe { record.Event.KeyEvent };
if key.bKeyDown == 0 {
continue;
}
let ch = unsafe { key.uChar.UnicodeChar };
if ch == 0 {
continue;
}
if let Ok(byte) = u8::try_from(ch) {
return Some(byte);
}
return None;
}
}
fn get_mode(handle: HANDLE) -> Option<CONSOLE_MODE> {
let mut mode: CONSOLE_MODE = 0;
(unsafe { GetConsoleMode(handle, &mut mode) } != 0).then_some(mode)
}
fn set_mode(handle: HANDLE, mode: CONSOLE_MODE) {
unsafe {
windows_sys::Win32::System::Console::SetConsoleMode(handle, mode);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_measured_replies() {
let cases: &[(&[u8], Rgb, Background)] = &[
(
b"\x1b]11;rgb:0c0c/0c0c/0c0c\x1b\\",
Rgb {
r: 12,
g: 12,
b: 12,
},
Background::Dark,
),
(
b"\x1b]11;rgb:1919/1a1a/1b1b\x1b\\",
Rgb {
r: 25,
g: 26,
b: 27,
},
Background::Dark,
),
(
b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\",
Rgb {
r: 255,
g: 255,
b: 255,
},
Background::Light,
),
(
b"\x1b]11;rgb:0c0c/0c0c/0c0c\x07",
Rgb {
r: 12,
g: 12,
b: 12,
},
Background::Dark,
),
];
for (raw, expected, verdict) in cases {
let rgb = parse_reply(raw).expect("a recorded reply must parse");
assert_eq!(rgb, *expected, "reply {raw:?}");
assert_eq!(classify(rgb), *verdict, "reply {raw:?}");
}
}
#[test]
fn accepts_every_component_width_and_the_hash_form() {
assert_eq!(
parse_reply(b"\x1b]11;rgb:f/f/f\x07"),
Some(Rgb {
r: 255,
g: 255,
b: 255
})
);
assert_eq!(
parse_reply(b"\x1b]11;rgb:fd/f6/e3\x1b\\"),
Some(Rgb {
r: 253,
g: 246,
b: 227
})
);
assert_eq!(
parse_reply(b"\x1b]11;rgb:000/000/000\x07"),
Some(Rgb { r: 0, g: 0, b: 0 })
);
assert_eq!(
parse_reply(b"\x1b]11;#282c34\x07"),
Some(Rgb {
r: 40,
g: 44,
b: 52
})
);
}
#[test]
fn rejects_what_is_not_a_colour() {
for raw in [
&b"\x1b]11;\x07"[..],
b"\x1b]11;rgb:zz/zz/zz\x07",
b"\x1b]11;rgb:1/2\x07",
b"\x1b]11;rgb:11111/2/3\x07",
b"",
b"garbage",
] {
assert_eq!(parse_reply(raw), None, "must not parse: {raw:?}");
}
}
#[test]
fn luminance_matches_the_measured_verdicts() {
let dark = relative_luminance(Rgb {
r: 12,
g: 12,
b: 12,
});
let light = relative_luminance(Rgb {
r: 255,
g: 255,
b: 255,
});
assert!(dark < 0.01, "conhost/WT background measured {dark}");
assert!((light - 1.0).abs() < f32::EPSILON, "white measured {light}");
assert!(dark < DARK_THRESHOLD && light > DARK_THRESHOLD);
}
#[test]
fn the_query_is_the_bytes_terminals_were_measured_with() {
assert_eq!(QUERY_BG.as_bytes(), b"\x1b]11;?\x07");
}
fn reader(
stream: &'static [u8],
) -> (
impl FnMut() -> Option<u8>,
std::rc::Rc<std::cell::Cell<usize>>,
) {
let taken = std::rc::Rc::new(std::cell::Cell::new(0usize));
let counter = std::rc::Rc::clone(&taken);
let next = move || {
let i = counter.get();
let b = stream.get(i).copied();
if b.is_some() {
counter.set(i + 1);
}
b
};
(next, taken)
}
#[test]
fn reads_a_reply_under_either_terminator() {
for stream in [
&b"\x1b]11;rgb:1e1e/1e1e/1e1e\x07"[..], b"\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\", ] {
let (next, _) = reader(stream);
assert_eq!(
read_reply(next),
Some(Rgb {
r: 30,
g: 30,
b: 30
}),
"stream {stream:?}"
);
}
}
#[test]
fn a_silent_terminal_costs_at_most_the_byte_that_proved_it() {
let (next, taken) = reader(b"");
assert_eq!(read_reply(next), None);
assert_eq!(taken.get(), 0, "silence must cost nothing");
let (next, taken) = reader(b"q");
assert_eq!(read_reply(next), None);
assert_eq!(taken.get(), 1, "one keystroke must cost exactly one byte");
}
#[test]
fn a_truncated_or_endless_reply_yields_nothing() {
for stream in [
&b"\x1b"[..], b"\x1b]11;rgb:11", b"\x1bA", ] {
let (next, _) = reader(stream);
assert_eq!(read_reply(next), None, "stream {stream:?}");
}
}
#[test]
fn a_terminal_that_never_stops_talking_is_bounded() {
const FLOOD: &[u8] = b"\x1b]11;rgb:0000/0000/0000\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
assert!(FLOOD.len() > MAX_REPLY);
let (next, taken) = reader(FLOOD);
assert_eq!(read_reply(next), None);
assert!(
taken.get() <= MAX_REPLY + 2,
"read {} bytes, past the cap",
taken.get()
);
}
}