mod detect;
use ratatui::style::Color;
use crate::theme::{ColorScheme, Theme};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
Kitty,
ITerm2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum ColorLevel {
#[default]
None,
Basic,
Ansi256,
TrueColor,
}
impl ColorLevel {
#[must_use]
pub fn has_color(self) -> bool {
self >= Self::Basic
}
#[must_use]
pub fn has_256(self) -> bool {
self >= Self::Ansi256
}
#[must_use]
pub fn has_truecolor(self) -> bool {
self >= Self::TrueColor
}
}
#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)]
pub struct TerminalCaps {
pub color_level: ColorLevel,
pub image_protocol: Option<ImageProtocol>,
pub true_color: bool,
pub hyperlinks: bool,
pub kitty_protocol: bool,
pub sixel: bool,
pub synchronized_output: bool,
pub deccara: bool,
pub cell_size: Option<(u16, u16)>,
pub terminal_name: Option<String>,
}
impl Default for TerminalCaps {
fn default() -> Self {
Self {
color_level: ColorLevel::None,
image_protocol: None,
true_color: false,
hyperlinks: false,
kitty_protocol: false,
sixel: false,
synchronized_output: true,
deccara: false,
cell_size: None,
terminal_name: None,
}
}
}
impl TerminalCaps {
#[must_use]
pub fn detect() -> Self {
detect::detect_with_env()
}
pub fn adapt_theme(&self, theme: &mut Theme) {
if self.color_level >= ColorLevel::TrueColor {
return; }
adapt_color_scheme(&mut theme.colors, self.color_level);
theme.styles = crate::theme::ThemeStyles::from_colors(&theme.colors);
}
#[must_use]
pub fn supports_images(&self) -> bool {
self.image_protocol.is_some()
}
}
#[allow(clippy::match_same_arms)] #[must_use]
pub fn adapt_color(color: Color, level: ColorLevel) -> Color {
match (color, level) {
(_, ColorLevel::None) => Color::Reset,
(Color::Reset, _) => Color::Reset,
(c, ColorLevel::TrueColor) => c,
(Color::Rgb(r, g, b), ColorLevel::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
(Color::Rgb(r, g, b), ColorLevel::Basic) => {
Color::Indexed(ansi256_to_basic(rgb_to_ansi256(r, g, b)))
}
(Color::DarkGray, ColorLevel::Basic | ColorLevel::Ansi256) => Color::Gray,
(
c @ (Color::LightRed
| Color::LightGreen
| Color::LightYellow
| Color::LightBlue
| Color::LightMagenta
| Color::LightCyan),
ColorLevel::Basic,
) => darken_light(c),
(Color::Indexed(idx), ColorLevel::Basic) => Color::Indexed(ansi256_to_basic(idx)),
(
c @ (Color::Black
| Color::Red
| Color::Green
| Color::Yellow
| Color::Blue
| Color::Magenta
| Color::Cyan
| Color::White
| Color::Gray
| Color::LightRed
| Color::LightGreen
| Color::LightYellow
| Color::LightBlue
| Color::LightMagenta
| Color::LightCyan
| Color::Indexed(_)),
ColorLevel::Basic | ColorLevel::Ansi256,
) => c,
}
}
fn darken_light(c: Color) -> Color {
match c {
Color::LightRed => Color::Red,
Color::LightGreen => Color::Green,
Color::LightYellow => Color::Yellow,
Color::LightBlue => Color::Blue,
Color::LightMagenta => Color::Magenta,
Color::LightCyan => Color::Cyan,
_ => c,
}
}
fn adapt_color_scheme(scheme: &mut ColorScheme, level: ColorLevel) {
macro_rules! adapt_field {
($f:ident) => {
scheme.$f = adapt_color(scheme.$f, level);
};
}
adapt_field!(background);
adapt_field!(foreground);
adapt_field!(primary);
adapt_field!(accent);
adapt_field!(muted);
adapt_field!(border);
adapt_field!(user);
adapt_field!(user_bg);
adapt_field!(response);
adapt_field!(response_bg);
adapt_field!(thinking);
adapt_field!(thinking_bg);
adapt_field!(tool);
adapt_field!(tool_bg);
adapt_field!(success);
adapt_field!(warning);
adapt_field!(error);
adapt_field!(info);
adapt_field!(diff_add);
adapt_field!(diff_remove);
adapt_field!(diff_hunk);
adapt_field!(surface_bg);
adapt_field!(panel_bg);
adapt_field!(code_bg);
adapt_field!(selection_bg);
adapt_field!(diff_add_bg);
adapt_field!(diff_remove_bg);
adapt_field!(diff_hunk_bg);
}
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
if r == g && g == b {
if r < 8 {
return 16;
}
if r > 248 {
return 231;
}
return u8::try_from(u16::from(r - 8) * 24 / 237 + 232).unwrap_or(232);
}
let ri = u16::from(r) * 5 / 255;
let gi = u16::from(g) * 5 / 255;
let bi = u16::from(b) * 5 / 255;
u8::try_from(16 + 36 * ri + 6 * gi + bi).unwrap_or(16)
}
fn ansi256_to_basic(idx: u8) -> u8 {
match idx {
0..=15 => idx, 16 => 0, 17..=51 => 4, 52..=87 | 124..=159 => 1, 88..=123 => 5, 160..=195 => 3, 196..=231 => {
if idx < 214 {
1
} else {
3
}
} 232..=255 => 7, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapt_color_resets_at_none_level() {
assert_eq!(adapt_color(Color::Red, ColorLevel::None), Color::Reset);
assert_eq!(
adapt_color(Color::Rgb(1, 2, 3), ColorLevel::None),
Color::Reset
);
assert_eq!(
adapt_color(Color::Indexed(42), ColorLevel::None),
Color::Reset
);
}
#[test]
fn adapt_color_keeps_truecolor_unchanged() {
let c = Color::Rgb(123, 45, 67);
assert_eq!(adapt_color(c, ColorLevel::TrueColor), c);
assert_eq!(adapt_color(Color::Red, ColorLevel::TrueColor), Color::Red);
}
#[test]
fn adapt_color_rgb_to_ansi256_red() {
assert_eq!(
adapt_color(Color::Rgb(255, 0, 0), ColorLevel::Ansi256),
Color::Indexed(196)
);
}
#[test]
fn adapt_color_keeps_basic_colors() {
assert_eq!(adapt_color(Color::Red, ColorLevel::Basic), Color::Red);
assert_eq!(adapt_color(Color::Cyan, ColorLevel::Basic), Color::Cyan);
assert_eq!(adapt_color(Color::DarkGray, ColorLevel::Basic), Color::Gray);
}
#[test]
fn adapt_color_collapses_light_to_basic_at_basic_level() {
assert_eq!(adapt_color(Color::LightRed, ColorLevel::Basic), Color::Red);
assert_eq!(
adapt_color(Color::LightCyan, ColorLevel::Basic),
Color::Cyan
);
assert_eq!(
adapt_color(Color::LightRed, ColorLevel::Ansi256),
Color::LightRed
);
}
#[test]
fn adapt_color_rgb_to_basic_via_index() {
assert_eq!(
adapt_color(Color::Rgb(255, 0, 0), ColorLevel::Basic),
Color::Indexed(1)
);
}
#[test]
fn no_color_env_returns_none_level() {
let level = detect::detect_color_level(Some("1"), "truecolor", "xterm-256color", true);
assert_eq!(level, ColorLevel::None);
}
#[test]
fn color_level_has_helpers() {
assert!(!ColorLevel::None.has_color());
assert!(ColorLevel::Basic.has_color());
assert!(!ColorLevel::Basic.has_256());
assert!(ColorLevel::Ansi256.has_256());
assert!(!ColorLevel::Ansi256.has_truecolor());
assert!(ColorLevel::TrueColor.has_truecolor());
}
#[test]
fn adapt_theme_skips_downgrade_at_truecolor() {
let mut theme = Theme::dark();
let original_bg = theme.colors.background;
let caps = TerminalCaps {
color_level: ColorLevel::TrueColor,
..TerminalCaps::default()
};
caps.adapt_theme(&mut theme);
assert_eq!(theme.colors.background, original_bg);
}
#[test]
fn adapt_theme_downgrades_rgb_to_ansi256() {
let mut theme = Theme::dark();
theme.colors.accent = Color::Rgb(255, 0, 0);
let caps = TerminalCaps {
color_level: ColorLevel::Ansi256,
..TerminalCaps::default()
};
caps.adapt_theme(&mut theme);
assert_eq!(theme.colors.accent, Color::Indexed(196));
assert_eq!(theme.styles.accent.fg, Some(Color::Indexed(196)));
}
#[test]
fn adapt_theme_at_none_resets_all_to_default_bg() {
let mut theme = Theme::dark();
let caps = TerminalCaps {
color_level: ColorLevel::None,
..TerminalCaps::default()
};
caps.adapt_theme(&mut theme);
assert_eq!(theme.colors.background, Color::Reset);
assert_eq!(theme.colors.foreground, Color::Reset);
assert_eq!(theme.colors.primary, Color::Reset);
assert_eq!(theme.colors.accent, Color::Reset);
}
#[test]
fn rgb_to_ansi256_pure_red_is_196() {
assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
}
#[test]
fn ansi256_to_basic_known_indices() {
assert_eq!(ansi256_to_basic(196), 1);
assert_eq!(ansi256_to_basic(21), 4);
assert_eq!(ansi256_to_basic(0), 0);
assert_eq!(ansi256_to_basic(15), 15);
assert_eq!(ansi256_to_basic(240), 7);
}
}