use std::time::{Duration, Instant};
use ratatui::style::{Color, Modifier, Style};
pub struct Theme {
pub border_focused: Style,
pub border_unfocused: Style,
pub title: Style,
pub hint_key: Style,
pub hint_label: Style,
pub selection: Style,
pub session_active: Style,
pub session_dead: Style,
pub session_meta: Style,
pub timestamp: Style,
pub text: Style,
pub accent: Style,
pub success: Style,
pub error: Style,
pub warning: Style,
pub info: Style,
pub muted: Style,
}
impl Default for Theme {
fn default() -> Self {
Self::new()
}
}
impl Theme {
#[must_use]
pub const fn new() -> Self {
Self {
border_focused: Style::new(),
border_unfocused: Style::new().add_modifier(Modifier::DIM),
title: Style::new().add_modifier(Modifier::BOLD),
hint_key: Style::new().add_modifier(Modifier::BOLD),
hint_label: Style::new().add_modifier(Modifier::DIM),
selection: Style::new().add_modifier(Modifier::REVERSED),
session_active: Style::new().fg(Color::Green),
session_dead: Style::new().add_modifier(Modifier::DIM),
session_meta: Style::new().add_modifier(Modifier::DIM),
timestamp: Style::new().add_modifier(Modifier::DIM),
text: Style::new(),
accent: Style::new().fg(Color::Cyan),
success: Style::new().fg(Color::Green),
error: Style::new().fg(Color::Red),
warning: Style::new().fg(Color::Yellow),
info: Style::new().fg(Color::Blue),
muted: Style::new().add_modifier(Modifier::DIM),
}
}
#[must_use]
pub fn detect() -> Self {
let mut theme = Self::new();
if let Some((r, g, b)) = detect_terminal_bg() {
theme.selection = Style::new().bg(selection_bg_from_terminal(r, g, b));
}
theme
}
}
const SELECTION_LIGHTNESS_SHIFT: f64 = 0.2;
const DETECTION_POLL: Duration = Duration::from_millis(200);
const DETECTION_TICK_THRESHOLD: i64 = 10;
const DETECTION_WALL_CAP: Duration = Duration::from_secs(2);
#[cfg(unix)]
fn detect_terminal_bg() -> Option<(u8, u8, u8)> {
use std::io::{Read, Write};
use std::sync::mpsc::RecvTimeoutError;
use catenary_proc::ProcessMonitor;
let mut tty = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
.ok()?;
let was_raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false);
if !was_raw {
crossterm::terminal::enable_raw_mode().ok()?;
}
tty.write_all(b"\x1b]11;?\x07").ok()?;
tty.flush().ok()?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::with_capacity(64);
let mut byte = [0u8; 1];
loop {
match tty.read(&mut byte) {
Ok(1) => {
buf.push(byte[0]);
if byte[0] == 0x07 {
let _ = tx.send(buf);
return;
}
if buf.len() >= 2 && buf[buf.len() - 2] == 0x1b && buf[buf.len() - 1] == b'\\' {
let _ = tx.send(buf);
return;
}
}
_ => return,
}
}
});
let mut monitor = ProcessMonitor::new(std::process::id())?;
let deadline = Instant::now() + DETECTION_WALL_CAP;
let mut remaining_threshold = DETECTION_TICK_THRESHOLD;
let result = loop {
match rx.recv_timeout(DETECTION_POLL) {
Ok(response) => break Some(response),
Err(RecvTimeoutError::Disconnected) => break None,
Err(RecvTimeoutError::Timeout) => {}
}
let d = monitor.sample()?;
if d.state == catenary_proc::ProcessState::Dead {
break None;
}
let delta = d.delta_utime + d.delta_stime;
if d.state == catenary_proc::ProcessState::Running && delta > 0 {
remaining_threshold -= i64::try_from(delta).unwrap_or(remaining_threshold);
}
if remaining_threshold <= 0 || Instant::now() >= deadline {
break None;
}
};
if !was_raw {
let _ = crossterm::terminal::disable_raw_mode();
}
result.and_then(|r| parse_osc11_response(&r))
}
#[cfg(not(unix))]
const fn detect_terminal_bg() -> Option<(u8, u8, u8)> {
None
}
fn parse_osc11_response(response: &[u8]) -> Option<(u8, u8, u8)> {
let text = std::str::from_utf8(response).ok()?;
let rgb_start = text.find("rgb:")?;
let rgb_part = &text[rgb_start + 4..];
let rgb_clean = rgb_part.trim_end_matches(['\x07', '\\', '\x1b']);
let mut channels = rgb_clean.splitn(3, '/');
let r_hex = channels.next()?;
let g_hex = channels.next()?;
let b_hex = channels.next()?;
Some((
parse_osc_channel(r_hex)?,
parse_osc_channel(g_hex)?,
parse_osc_channel(b_hex)?,
))
}
fn parse_osc_channel(hex: &str) -> Option<u8> {
let val = u16::from_str_radix(hex, 16).ok()?;
#[allow(
clippy::cast_possible_truncation,
reason = "intentional 16-to-8-bit conversion"
)]
let byte = match hex.len() {
4 => (val >> 8) as u8,
3 => (val >> 4) as u8,
2 => val as u8,
1 => (val * 17) as u8, _ => return None,
};
Some(byte)
}
#[allow(
clippy::many_single_char_names,
reason = "r/g/b/h/s/l are standard color math notation"
)]
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
let rf = f64::from(r) / 255.0;
let gf = f64::from(g) / 255.0;
let bf = f64::from(b) / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let delta = max - min;
let light = f64::midpoint(max, min);
if delta < f64::EPSILON {
return (0.0, 0.0, light);
}
let sat = if light <= 0.5 {
delta / (max + min)
} else {
delta / (2.0 - max - min)
};
let hue_sector = if (max - rf).abs() < f64::EPSILON {
((gf - bf) / delta) % 6.0
} else if (max - gf).abs() < f64::EPSILON {
(bf - rf) / delta + 2.0
} else {
(rf - gf) / delta + 4.0
};
let hue = hue_sector * 60.0;
let hue = if hue < 0.0 { hue + 360.0 } else { hue };
(hue, sat, light)
}
#[allow(
clippy::many_single_char_names,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "standard HSL math with clamped f64 to u8 conversion"
)]
fn hsl_to_rgb(hue: f64, sat: f64, light: f64) -> (u8, u8, u8) {
if sat < f64::EPSILON {
let val = (light * 255.0).round() as u8;
return (val, val, val);
}
let q_val = if light < 0.5 {
light * (1.0 + sat)
} else {
light.mul_add(-sat, light + sat)
};
let p_val = 2.0f64.mul_add(light, -q_val);
let h_norm = hue / 360.0;
let channel = |tc: f64| -> u8 {
let tc = if tc < 0.0 {
tc + 1.0
} else if tc > 1.0 {
tc - 1.0
} else {
tc
};
let out = if tc < 1.0 / 6.0 {
((q_val - p_val) * 6.0).mul_add(tc, p_val)
} else if tc < 0.5 {
q_val
} else if tc < 2.0 / 3.0 {
((q_val - p_val) * (2.0 / 3.0 - tc)).mul_add(6.0, p_val)
} else {
p_val
};
(out * 255.0).round() as u8
};
(
channel(h_norm + 1.0 / 3.0),
channel(h_norm),
channel(h_norm - 1.0 / 3.0),
)
}
#[allow(
clippy::many_single_char_names,
reason = "r/g/b are standard color notation"
)]
fn selection_bg_from_terminal(r: u8, g: u8, b: u8) -> Color {
let (hue, sat, light) = rgb_to_hsl(r, g, b);
let new_light = if light < 0.5 {
(light + SELECTION_LIGHTNESS_SHIFT).min(1.0)
} else {
(light - SELECTION_LIGHTNESS_SHIFT).max(0.0)
};
let (nr, ng, nb) = hsl_to_rgb(hue, sat, new_light);
Color::Rgb(nr, ng, nb)
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
mod tests {
use super::*;
#[test]
fn test_theme_construction() {
let theme = Theme::new();
assert!(!theme.border_focused.add_modifier.contains(Modifier::DIM));
assert!(theme.border_unfocused.add_modifier.contains(Modifier::DIM));
}
#[test]
fn test_rgb_to_hsl_black() {
let (h, s, l) = rgb_to_hsl(0, 0, 0);
assert!((h - 0.0).abs() < 0.01);
assert!((s - 0.0).abs() < 0.01);
assert!((l - 0.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsl_white() {
let (h, s, l) = rgb_to_hsl(255, 255, 255);
assert!((h - 0.0).abs() < 0.01);
assert!((s - 0.0).abs() < 0.01);
assert!((l - 1.0).abs() < 0.01);
}
#[test]
fn test_rgb_to_hsl_pure_red() {
let (h, s, l) = rgb_to_hsl(255, 0, 0);
assert!((h - 0.0).abs() < 0.01);
assert!((s - 1.0).abs() < 0.01);
assert!((l - 0.5).abs() < 0.01);
}
#[test]
#[allow(
clippy::many_single_char_names,
reason = "r/g/b/h/s/l are standard color math notation"
)]
fn test_hsl_roundtrip_dark_gray() {
let (r, g, b) = (30u8, 30, 30);
let (h, s, l) = rgb_to_hsl(r, g, b);
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert!((i16::from(r) - i16::from(r2)).abs() <= 1);
assert!((i16::from(g) - i16::from(g2)).abs() <= 1);
assert!((i16::from(b) - i16::from(b2)).abs() <= 1);
}
#[test]
#[allow(
clippy::many_single_char_names,
reason = "r/g/b/h/s/l are standard color math notation"
)]
fn test_hsl_roundtrip_color() {
let (r, g, b) = (50u8, 130, 180);
let (h, s, l) = rgb_to_hsl(r, g, b);
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert!((i16::from(r) - i16::from(r2)).abs() <= 1);
assert!((i16::from(g) - i16::from(g2)).abs() <= 1);
assert!((i16::from(b) - i16::from(b2)).abs() <= 1);
}
#[test]
fn test_selection_bg_dark_background_lightens() {
let Color::Rgb(r, g, b) = selection_bg_from_terminal(26, 26, 26) else {
unreachable!("selection_bg_from_terminal always returns Color::Rgb");
};
assert!(r > 26, "red channel should increase: {r}");
assert!(g > 26, "green channel should increase: {g}");
assert!(b > 26, "blue channel should increase: {b}");
}
#[test]
fn test_selection_bg_light_background_darkens() {
let Color::Rgb(r, g, b) = selection_bg_from_terminal(240, 240, 240) else {
unreachable!("selection_bg_from_terminal always returns Color::Rgb");
};
assert!(r < 240, "red channel should decrease: {r}");
assert!(g < 240, "green channel should decrease: {g}");
assert!(b < 240, "blue channel should decrease: {b}");
}
#[test]
fn test_selection_bg_preserves_hue() {
let Color::Rgb(r, g, b) = selection_bg_from_terminal(20, 20, 40) else {
unreachable!("selection_bg_from_terminal always returns Color::Rgb");
};
assert!(b >= r, "blue should still dominate: r={r} b={b}");
assert!(b >= g, "blue should still dominate: g={g} b={b}");
}
#[test]
fn test_parse_osc11_response_4digit() {
let response = b"\x1b]11;rgb:1a1a/1a1a/1a1a\x07";
let result = parse_osc11_response(response);
assert_eq!(result, Some((0x1a, 0x1a, 0x1a)));
}
#[test]
fn test_parse_osc11_response_2digit() {
let response = b"\x1b]11;rgb:1a/1a/1a\x07";
let result = parse_osc11_response(response);
assert_eq!(result, Some((0x1a, 0x1a, 0x1a)));
}
#[test]
fn test_parse_osc11_response_st_terminator() {
let response = b"\x1b]11;rgb:ffff/0000/8080\x1b\\";
let result = parse_osc11_response(response);
assert_eq!(result, Some((0xff, 0x00, 0x80)));
}
#[test]
fn test_parse_osc11_garbage() {
let response = b"not a valid response";
assert!(parse_osc11_response(response).is_none());
}
#[test]
fn test_parse_osc_channel_variants() {
assert_eq!(parse_osc_channel("ff"), Some(0xff));
assert_eq!(parse_osc_channel("ffff"), Some(0xff));
assert_eq!(parse_osc_channel("0000"), Some(0x00));
assert_eq!(parse_osc_channel("8080"), Some(0x80));
assert_eq!(parse_osc_channel("f"), Some(0xff));
assert_eq!(parse_osc_channel("0"), Some(0x00));
}
#[test]
fn test_theme_detect_fallback() {
let theme = Theme::detect();
let _ = theme.selection;
}
}