use ratatui::style::Color;
use std::io::{Read, Write};
use std::time::{Duration, Instant};
use super::types::Theme;
const OSC_QUERY_TIMEOUT_MS: u64 = 250;
const DRAIN_ITERATIONS: usize = 3;
const DRAIN_DELAY_MS: u64 = 10;
fn drain_stray_events() {
use crossterm::event::{poll, read as crossterm_read};
for _ in 0..DRAIN_ITERATIONS {
while poll(Duration::from_millis(0)).unwrap_or(false) {
let _ = crossterm_read();
}
std::thread::sleep(Duration::from_millis(DRAIN_DELAY_MS));
}
while poll(Duration::from_millis(0)).unwrap_or(false) {
let _ = crossterm_read();
}
}
#[must_use]
pub fn query_terminal_colors() -> Option<(Color, Color)> {
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() {
tracing::debug!("Not a terminal, skipping OSC query");
return None;
}
if let Some(colors) = query_with_raw_mode() {
return Some(colors);
}
std::thread::sleep(Duration::from_millis(50));
tracing::debug!("Retrying OSC query after initial failure");
query_with_raw_mode()
}
fn query_with_raw_mode() -> Option<(Color, Color)> {
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, is_raw_mode_enabled};
let was_raw_mode = is_raw_mode_enabled().unwrap_or(false);
let is_headless = std::env::var("PACSEA_TEST_HEADLESS").ok().as_deref() == Some("1");
tracing::debug!(was_raw_mode, is_headless, "Starting terminal color query");
if !is_headless {
let _ = execute!(std::io::stdout(), DisableMouseCapture);
}
if !was_raw_mode && enable_raw_mode().is_err() {
tracing::debug!("Failed to enable raw mode for OSC query");
return None;
}
drain_stray_events();
let result = (|| {
let mut stdout = std::io::stdout();
write!(stdout, "\x1b]10;?\x07\x1b]11;?\x07").ok()?;
stdout.flush().ok()?;
let response = read_with_timeout(Duration::from_millis(OSC_QUERY_TIMEOUT_MS))?;
tracing::debug!(response_len = response.len(), "Received OSC response");
let fg = parse_osc_color_response(&response, 10);
let bg = parse_osc_color_response(&response, 11);
if fg.is_none() || bg.is_none() {
tracing::debug!(
fg_parsed = fg.is_some(),
bg_parsed = bg.is_some(),
"Failed to parse OSC color response"
);
return None;
}
Some((fg?, bg?))
})();
drain_stray_events();
if !was_raw_mode {
let _ = disable_raw_mode();
}
if !is_headless && was_raw_mode {
let _ = execute!(std::io::stdout(), EnableMouseCapture);
}
result
}
const OSC_READ_MAX_BYTES: usize = 4096;
fn read_with_timeout(timeout: Duration) -> Option<String> {
#[cfg(unix)]
return read_with_timeout_unix(timeout);
#[cfg(not(unix))]
return read_with_timeout_thread_joined(timeout);
}
#[cfg(unix)]
fn read_with_timeout_unix(timeout: Duration) -> Option<String> {
use nix::fcntl::{FcntlArg, OFlag, fcntl};
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use std::os::fd::BorrowedFd;
use std::os::unix::io::AsRawFd;
let mut tty = std::fs::File::open("/dev/tty").ok()?;
let raw_fd = tty.as_raw_fd();
let fd = unsafe { BorrowedFd::borrow_raw(raw_fd) };
let flags = fcntl(fd, FcntlArg::F_GETFL).ok()?;
let mut oflags = OFlag::from_bits_truncate(flags);
oflags.insert(OFlag::O_NONBLOCK);
fcntl(fd, FcntlArg::F_SETFL(oflags)).ok()?;
let deadline = Instant::now() + timeout;
let mut result = Vec::new();
let mut buffer = [0u8; 512];
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
#[allow(clippy::cast_possible_truncation)]
let ms = remaining.as_millis().min(u128::from(u16::MAX)) as u16;
let poll_timeout = PollTimeout::from(ms);
let poll_fd = unsafe { BorrowedFd::borrow_raw(raw_fd) };
let mut poll_fds = [PollFd::new(poll_fd, PollFlags::POLLIN)];
match poll(&mut poll_fds, poll_timeout) {
Ok(0) => {}
Ok(_) => {
loop {
match tty.read(&mut buffer) {
Ok(0) => break,
Ok(n) => result.extend_from_slice(&buffer[..n]),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => break,
}
}
if result.len() > OSC_READ_MAX_BYTES {
break;
}
let s = String::from_utf8_lossy(&result).to_string();
if parse_osc_color_response(&s, 10).is_some()
&& parse_osc_color_response(&s, 11).is_some()
{
return Some(s);
}
}
Err(_) => break,
}
}
None
}
#[cfg(windows)]
const READER_POLL_MS: u32 = 50;
#[cfg(all(not(unix), windows))]
fn read_with_timeout_thread_joined(timeout: Duration) -> Option<String> {
use std::os::windows::io::AsRawHandle;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
let cancel = Arc::new(AtomicBool::new(false));
let cancel_reader = Arc::clone(&cancel);
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let mut result = Vec::new();
let mut buffer = [0u8; 512];
let stdin = std::io::stdin();
let raw_handle = stdin.as_raw_handle();
loop {
if cancel_reader.load(Ordering::Relaxed) {
break;
}
let wait_ms = READER_POLL_MS;
let ret = unsafe {
windows_sys::Win32::System::Threading::WaitForSingleObject(
raw_handle as *mut _,
wait_ms,
)
};
if ret == windows_sys::Win32::System::Threading::WAIT_TIMEOUT {
continue;
}
if ret != windows_sys::Win32::System::Threading::WAIT_OBJECT_0 {
break;
}
let n = match stdin.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
result.extend_from_slice(&buffer[..n]);
if result.len() > OSC_READ_MAX_BYTES {
break;
}
let s = String::from_utf8_lossy(&result).to_string();
if parse_osc_color_response(&s, 10).is_some()
&& parse_osc_color_response(&s, 11).is_some()
{
break;
}
}
let _ = tx.send(String::from_utf8_lossy(&result).to_string());
});
let out = rx.recv_timeout(timeout).ok();
cancel.store(true, Ordering::Relaxed);
let _ = handle.join();
out
}
#[cfg(all(not(unix), not(windows)))]
fn read_with_timeout_thread_joined(timeout: Duration) -> Option<String> {
use std::sync::mpsc;
use std::thread;
const JOIN_TIMEOUT: Duration = Duration::from_secs(2);
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let mut result = Vec::new();
let mut buffer = [0u8; 512];
let mut stdin = std::io::stdin();
loop {
let n = match stdin.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
result.extend_from_slice(&buffer[..n]);
if result.len() > OSC_READ_MAX_BYTES {
break;
}
let s = String::from_utf8_lossy(&result).to_string();
if parse_osc_color_response(&s, 10).is_some()
&& parse_osc_color_response(&s, 11).is_some()
{
break;
}
}
let _ = tx.send(String::from_utf8_lossy(&result).to_string());
});
let out = rx.recv_timeout(timeout).ok();
let (join_tx, join_rx) = mpsc::channel();
let join_handle = handle;
thread::spawn(move || {
let _ = join_handle.join();
let _ = join_tx.send(());
});
let _ = join_rx.recv_timeout(JOIN_TIMEOUT);
out
}
fn parse_osc_color_response(response: &str, code: u8) -> Option<Color> {
let code_str = format!("]{code};");
let start = response.find(&code_str)?;
let after_code = &response[start + code_str.len()..];
let rgb_start = if after_code.starts_with("rgb:") {
4
} else if after_code.starts_with("rgba:") {
5 } else {
return None;
};
let color_part = &after_code[rgb_start..];
let end = color_part
.find('\x07')
.or_else(|| color_part.find('\x1b'))
.unwrap_or(color_part.len());
let color_str = &color_part[..end];
parse_rgb_color(color_str)
}
fn parse_rgb_color(s: &str) -> Option<Color> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() < 3 {
return None;
}
let r = parse_color_component(parts[0])?;
let g = parse_color_component(parts[1])?;
let b = parse_color_component(parts[2])?;
Some(Color::Rgb(r, g, b))
}
fn parse_color_component(s: &str) -> Option<u8> {
let hex = if s.len() == 4 { &s[0..2] } else { s };
u8::from_str_radix(hex, 16).ok()
}
#[must_use]
pub fn theme_from_fg_bg(fg: Color, bg: Color) -> Theme {
let (fg_r, fg_g, fg_b) = color_to_rgb(fg);
let (bg_r, bg_g, bg_b) = color_to_rgb(bg);
let bg_luminance = luminance(bg_r, bg_g, bg_b);
let is_dark = bg_luminance < 0.5;
let (crust, mantle) = if is_dark {
let crust = darken(bg_r, bg_g, bg_b, 0.15);
let mantle = darken(bg_r, bg_g, bg_b, 0.08);
(crust, mantle)
} else {
let crust = lighten(bg_r, bg_g, bg_b, 0.15);
let mantle = lighten(bg_r, bg_g, bg_b, 0.08);
(crust, mantle)
};
let (surface1, surface2) = if is_dark {
(
lighten(bg_r, bg_g, bg_b, 0.10),
lighten(bg_r, bg_g, bg_b, 0.15),
)
} else {
(
darken(bg_r, bg_g, bg_b, 0.08),
darken(bg_r, bg_g, bg_b, 0.12),
)
};
let (overlay1, overlay2) = if is_dark {
(
lighten(bg_r, bg_g, bg_b, 0.25),
lighten(bg_r, bg_g, bg_b, 0.35),
)
} else {
(
darken(bg_r, bg_g, bg_b, 0.25),
darken(bg_r, bg_g, bg_b, 0.35),
)
};
let subtext0 = blend(fg_r, fg_g, fg_b, bg_r, bg_g, bg_b, 0.75);
let subtext1 = blend(fg_r, fg_g, fg_b, bg_r, bg_g, bg_b, 0.85);
let (sapphire, mauve, green, yellow, red, lavender) = if is_dark {
(
Color::Rgb(116, 199, 236), Color::Rgb(203, 166, 247), Color::Rgb(166, 227, 161), Color::Rgb(249, 226, 175), Color::Rgb(243, 139, 168), Color::Rgb(180, 190, 254), )
} else {
(
Color::Rgb(30, 102, 245), Color::Rgb(136, 57, 239), Color::Rgb(64, 160, 43), Color::Rgb(223, 142, 29), Color::Rgb(210, 15, 57), Color::Rgb(114, 135, 253), )
};
Theme {
base: bg,
mantle: rgb_to_color(mantle),
crust: rgb_to_color(crust),
surface1: rgb_to_color(surface1),
surface2: rgb_to_color(surface2),
overlay1: rgb_to_color(overlay1),
overlay2: rgb_to_color(overlay2),
text: fg,
subtext0: rgb_to_color(subtext0),
subtext1: rgb_to_color(subtext1),
sapphire,
mauve,
green,
yellow,
red,
lavender,
}
}
const fn color_to_rgb(color: Color) -> (u8, u8, u8) {
match color {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::White => (255, 255, 255),
Color::Red => (255, 0, 0),
Color::Green => (0, 255, 0),
Color::Blue => (0, 0, 255),
Color::Yellow => (255, 255, 0),
Color::Cyan => (0, 255, 255),
Color::Magenta => (255, 0, 255),
Color::DarkGray => (64, 64, 64),
Color::LightRed => (255, 128, 128),
Color::LightGreen => (128, 255, 128),
Color::LightBlue => (128, 128, 255),
Color::LightYellow => (255, 255, 128),
Color::LightCyan => (128, 255, 255),
Color::LightMagenta => (255, 128, 255),
Color::Gray | Color::Indexed(_) | Color::Reset => (128, 128, 128),
}
}
const fn rgb_to_color((r, g, b): (u8, u8, u8)) -> Color {
Color::Rgb(r, g, b)
}
fn luminance(r: u8, g: u8, b: u8) -> f32 {
let r_lin = srgb_to_linear(r);
let g_lin = srgb_to_linear(g);
let b_lin = srgb_to_linear(b);
0.2126f32.mul_add(r_lin, 0.7152f32.mul_add(g_lin, 0.0722 * b_lin))
}
fn clamp_f32_to_u8(v: f32) -> u8 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let n = (v.round() as i32).clamp(0, 255) as u8;
n
}
fn srgb_to_linear(c: u8) -> f32 {
let c = f32::from(c) / 255.0;
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn darken(r: u8, g: u8, b: u8, factor: f32) -> (u8, u8, u8) {
let factor = 1.0 - factor;
(
clamp_f32_to_u8(f32::from(r) * factor),
clamp_f32_to_u8(f32::from(g) * factor),
clamp_f32_to_u8(f32::from(b) * factor),
)
}
fn lighten(r: u8, g: u8, b: u8, factor: f32) -> (u8, u8, u8) {
(
clamp_f32_to_u8((255.0 - f32::from(r)).mul_add(factor, f32::from(r))),
clamp_f32_to_u8((255.0 - f32::from(g)).mul_add(factor, f32::from(g))),
clamp_f32_to_u8((255.0 - f32::from(b)).mul_add(factor, f32::from(b))),
)
}
fn blend(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8, factor: f32) -> (u8, u8, u8) {
let inv = 1.0 - factor;
(
clamp_f32_to_u8(f32::from(r1).mul_add(factor, f32::from(r2) * inv)),
clamp_f32_to_u8(f32::from(g1).mul_add(factor, f32::from(g2) * inv)),
clamp_f32_to_u8(f32::from(b1).mul_add(factor, f32::from(b2) * inv)),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_rgb_color_4digit() {
let color = parse_rgb_color("ffff/0000/8080");
let color = color.expect("valid 4-digit RGB");
let Color::Rgb(r, g, b) = color else {
panic!("Expected RGB color");
};
assert_eq!(r, 0xff);
assert_eq!(g, 0x00);
assert_eq!(b, 0x80);
}
#[test]
fn test_parse_rgb_color_2digit() {
let color = parse_rgb_color("cd/d6/f4");
let color = color.expect("valid 2-digit RGB");
let Color::Rgb(r, g, b) = color else {
panic!("Expected RGB color");
};
assert_eq!(r, 0xcd);
assert_eq!(g, 0xd6);
assert_eq!(b, 0xf4);
}
#[test]
fn test_theme_from_fg_bg_dark() {
let fg = Color::Rgb(205, 214, 244); let bg = Color::Rgb(30, 30, 46);
let theme = theme_from_fg_bg(fg, bg);
assert!(matches!(theme.base, Color::Rgb(30, 30, 46)));
assert!(matches!(theme.text, Color::Rgb(205, 214, 244)));
let Color::Rgb(m_r, m_g, m_b) = theme.mantle else {
panic!("Expected RGB");
};
let Color::Rgb(b_r, b_g, b_b) = theme.base else {
panic!("Expected RGB");
};
assert!(
m_r <= b_r && m_g <= b_g && m_b <= b_b,
"mantle should be darker than base for dark theme"
);
}
#[test]
fn test_theme_from_fg_bg_light() {
let fg = Color::Rgb(28, 28, 34); let bg = Color::Rgb(245, 245, 247);
let theme = theme_from_fg_bg(fg, bg);
assert!(matches!(theme.base, Color::Rgb(245, 245, 247)));
assert!(matches!(theme.text, Color::Rgb(28, 28, 34)));
let Color::Rgb(m_r, m_g, m_b) = theme.mantle else {
panic!("Expected RGB");
};
let Color::Rgb(b_r, b_g, b_b) = theme.base else {
panic!("Expected RGB");
};
assert!(
m_r >= b_r && m_g >= b_g && m_b >= b_b,
"mantle should be lighter than base for light theme"
);
}
#[test]
fn test_luminance() {
assert!((luminance(0, 0, 0) - 0.0).abs() < 0.01);
assert!((luminance(255, 255, 255) - 1.0).abs() < 0.01);
let gray_lum = luminance(128, 128, 128);
assert!(
gray_lum > 0.1 && gray_lum < 0.4,
"Gray luminance {gray_lum} should be between 0.1 and 0.4 (sRGB gamma)"
);
}
}