use std::io::{self, IsTerminal, Read, Stdout, Write};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crossterm::{
event::{DisableBracketedPaste, EnableBracketedPaste},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, prelude::CrosstermBackend};
use crate::ui::Theme;
const QUERY_TIMEOUT: Duration = Duration::from_millis(150);
const FENCE_GRACE: Duration = Duration::from_millis(20);
const FALLBACK: Theme = Theme::Dark;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ThemePreference {
#[default]
Auto,
Fixed(Theme),
}
impl ThemePreference {
pub fn parse(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" | "" => Ok(Self::Auto),
"dark" => Ok(Self::Fixed(Theme::Dark)),
"light" => Ok(Self::Fixed(Theme::Light)),
"mono" | "monochrome" => Ok(Self::Fixed(Theme::Mono)),
other => Err(anyhow::anyhow!(
"theme: expected \"dark\", \"light\", \"mono\" or \"auto\", found \"{other}\""
)),
}
}
pub fn resolve(
no_colour_flag: bool,
configured: Option<&str>,
no_colour_env: bool,
) -> Result<Self> {
let preference = match configured {
Some(value) => Self::parse(value)?,
None => Self::default(),
};
if no_colour_flag || no_colour_env {
return Ok(Self::Fixed(Theme::Mono));
}
Ok(preference)
}
}
pub fn no_colour_in_env() -> bool {
std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty())
}
pub fn init(preference: ThemePreference) -> Result<(Terminal<CrosstermBackend<Stdout>>, Theme)> {
enable_raw_mode().context("failed to enable raw mode")?;
let theme = match preference {
ThemePreference::Fixed(theme) => theme,
ThemePreference::Auto => detect_theme(),
};
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)
.context("failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("failed to create terminal instance")?;
Ok((terminal, theme))
}
pub fn restore(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode().context("failed to disable raw mode")?;
execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
)
.context("failed to leave alternate screen")?;
terminal.show_cursor().context("failed to show cursor")
}
fn detect_theme() -> Theme {
if io::stdin().is_terminal()
&& io::stdout().is_terminal()
&& let Some(background) = query_background(QUERY_TIMEOUT)
{
return theme_for(background);
}
std::env::var("COLORFGBG")
.ok()
.and_then(|value| theme_from_colorfgbg(&value))
.unwrap_or(FALLBACK)
}
fn theme_for((r, g, b): (u8, u8, u8)) -> Theme {
let luminance = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32;
if luminance >= 128.0 {
Theme::Light
} else {
Theme::Dark
}
}
#[cfg(unix)]
fn query_background(timeout: Duration) -> Option<(u8, u8, u8)> {
let mut stdout = io::stdout();
stdout.write_all(b"\x1b]11;?\x1b\\\x1b[c").ok()?;
stdout.flush().ok()?;
let mut deadline = Instant::now() + timeout;
let mut reply = Vec::new();
let mut chunk = [0u8; 128];
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() || !readable(remaining) {
break;
}
match io::stdin().read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => reply.extend_from_slice(&chunk[..read]),
}
if ends_device_attributes(&reply) {
if parse_background(&reply).is_some() {
break;
}
deadline = deadline.min(Instant::now() + FENCE_GRACE);
}
}
parse_background(&reply)
}
#[cfg(not(unix))]
fn query_background(_timeout: Duration) -> Option<(u8, u8, u8)> {
None
}
#[cfg(unix)]
fn readable(timeout: Duration) -> bool {
let mut poll_fd = libc::pollfd {
fd: libc::STDIN_FILENO,
events: libc::POLLIN,
revents: 0,
};
let millis = timeout.as_millis().min(i32::MAX as u128) as i32;
unsafe { libc::poll(&mut poll_fd, 1, millis) > 0 }
}
fn parse_background(reply: &[u8]) -> Option<(u8, u8, u8)> {
let text = String::from_utf8_lossy(reply);
let body = text.split("\x1b]11;").nth(1)?;
let end = body.find(['\x07', '\x1b'])?;
let components = body[..end].strip_prefix("rgb:")?;
let mut parts = components.split('/');
let r = scale_component(parts.next()?)?;
let g = scale_component(parts.next()?)?;
let b = scale_component(parts.next()?)?;
if parts.next().is_some() {
return None;
}
Some((r, g, b))
}
fn scale_component(digits: &str) -> Option<u8> {
let digits = digits.trim();
if digits.is_empty() || digits.len() > 4 {
return None;
}
let value = u32::from_str_radix(digits, 16).ok()?;
let max = (1u32 << (4 * digits.len())) - 1;
Some((value * 255 / max) as u8)
}
fn ends_device_attributes(reply: &[u8]) -> bool {
reply
.windows(3)
.position(|window| window == b"\x1b[?")
.is_some_and(|start| reply[start + 3..].contains(&b'c'))
}
fn theme_from_colorfgbg(value: &str) -> Option<Theme> {
let background: u8 = value.rsplit(';').next()?.trim().parse().ok()?;
match background {
0..=6 | 8 => Some(Theme::Dark),
7 | 9..=15 => Some(Theme::Light),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_configured_theme_is_taken_literally() {
assert_eq!(
ThemePreference::parse("dark").unwrap(),
ThemePreference::Fixed(Theme::Dark)
);
assert_eq!(
ThemePreference::parse(" Light ").unwrap(),
ThemePreference::Fixed(Theme::Light)
);
assert_eq!(
ThemePreference::parse("auto").unwrap(),
ThemePreference::Auto
);
}
#[test]
fn an_unknown_theme_is_rejected() {
let error = ThemePreference::parse("solarized").expect_err("unknown theme");
assert!(error.to_string().contains("solarized"), "{error}");
}
#[test]
fn asking_for_no_colour_is_asking_for_the_monochrome_theme() {
assert_eq!(
ThemePreference::parse("mono").unwrap(),
ThemePreference::Fixed(Theme::Mono)
);
for configured in [None, Some("dark"), Some("light"), Some("auto")] {
for (flag, env) in [(true, false), (false, true), (true, true)] {
assert_eq!(
ThemePreference::resolve(flag, configured, env).unwrap(),
ThemePreference::Fixed(Theme::Mono),
"{configured:?} with --no-color={flag} and NO_COLOR={env}"
);
}
}
}
#[test]
fn the_configured_theme_decides_when_colour_is_allowed() {
assert_eq!(
ThemePreference::resolve(false, Some("light"), false).unwrap(),
ThemePreference::Fixed(Theme::Light)
);
assert_eq!(
ThemePreference::resolve(false, None, false).unwrap(),
ThemePreference::Auto
);
}
#[test]
fn a_misspelt_theme_survives_resolution() {
for (flag, env) in [(false, false), (false, true), (true, true)] {
let error =
ThemePreference::resolve(flag, Some("greyscale"), env).expect_err("unknown theme");
assert!(error.to_string().contains("greyscale"), "{error}");
}
}
#[test]
fn a_background_query_answer_is_understood() {
assert_eq!(
parse_background(b"\x1b]11;rgb:2e2e/3434/3636\x1b\\"),
Some((46, 52, 54))
);
assert_eq!(
parse_background(b"\x1b]11;rgb:ee/ee/ec\x07"),
Some((238, 238, 236))
);
assert_eq!(
parse_background(b"\x1b]11;rgb:f/f/f\x07"),
Some((255, 255, 255))
);
assert_eq!(
parse_background(b"\x1b[?62;c\x1b]11;rgb:0000/0000/0000\x07"),
Some((0, 0, 0))
);
}
#[test]
fn a_half_arrived_or_nonsense_answer_is_not_guessed_at() {
assert_eq!(parse_background(b"\x1b]11;rgb:2e2e/3434/36"), None);
assert_eq!(parse_background(b"\x1b[?62;1;c"), None);
assert_eq!(parse_background(b""), None);
assert_eq!(parse_background(b"\x1b]11;cmy:0.1/0.2/0.3\x07"), None);
assert_eq!(parse_background(b"\x1b]11;rgb:11/22/33/44\x07"), None);
}
#[test]
fn brightness_decides_which_theme_a_background_is() {
assert_eq!(theme_for((46, 52, 54)), Theme::Dark);
assert_eq!(theme_for((238, 238, 236)), Theme::Light);
assert_eq!(theme_for((0, 0, 255)), Theme::Dark);
assert_eq!(theme_for((0, 200, 0)), Theme::Light);
}
#[test]
fn the_da1_fence_is_recognised() {
assert!(ends_device_attributes(b"\x1b[?62;1;6c"));
assert!(ends_device_attributes(b"\x1b]11;rgb:00/00/00\x07\x1b[?6c"));
assert!(!ends_device_attributes(b"\x1b[?62;1;6"));
assert!(!ends_device_attributes(b"\x1b]11;rgb:00/00/00\x07"));
}
#[test]
fn colorfgbg_says_which_background_it_has() {
assert_eq!(theme_from_colorfgbg("15;0"), Some(Theme::Dark));
assert_eq!(theme_from_colorfgbg("0;15"), Some(Theme::Light));
assert_eq!(theme_from_colorfgbg("15;default;0"), Some(Theme::Dark));
assert_eq!(theme_from_colorfgbg("0;default;7"), Some(Theme::Light));
assert_eq!(theme_from_colorfgbg("15;default"), None);
assert_eq!(theme_from_colorfgbg("12;250"), None);
assert_eq!(theme_from_colorfgbg(""), None);
}
}