use ftui::render::cell::PackedRgba;
use ftui::{Color, ColorCache, ColorProfile, Style, StyleSheet, Theme};
use crate::ui::components::theme::{self as legacy, ThemePalette, ThemePreset};
const ENV_NO_COLOR: &str = "NO_COLOR";
const ENV_CASS_NO_COLOR: &str = "CASS_NO_COLOR";
const ENV_CASS_NO_ICONS: &str = "CASS_NO_ICONS";
const ENV_CASS_NO_GRADIENT: &str = "CASS_NO_GRADIENT";
const ENV_CASS_DISABLE_ANIMATIONS: &str = "CASS_DISABLE_ANIMATIONS";
const ENV_CASS_ANIM: &str = "CASS_ANIM";
const ENV_CASS_A11Y: &str = "CASS_A11Y";
pub mod style_ids {
pub const TEXT_PRIMARY: &str = "text.primary";
pub const TEXT_SECONDARY: &str = "text.secondary";
pub const TEXT_MUTED: &str = "text.muted";
pub const TEXT_DISABLED: &str = "text.disabled";
pub const ACCENT_PRIMARY: &str = "accent.primary";
pub const ACCENT_SECONDARY: &str = "accent.secondary";
pub const ACCENT_TERTIARY: &str = "accent.tertiary";
pub const BG_DEEP: &str = "bg.deep";
pub const BG_SURFACE: &str = "bg.surface";
pub const BG_HIGHLIGHT: &str = "bg.highlight";
pub const BORDER: &str = "border";
pub const BORDER_FOCUS: &str = "border.focus";
pub const BORDER_MINIMAL: &str = "border.minimal";
pub const BORDER_EMPHASIZED: &str = "border.emphasized";
pub const ROLE_USER: &str = "role.user";
pub const ROLE_AGENT: &str = "role.agent";
pub const ROLE_TOOL: &str = "role.tool";
pub const ROLE_SYSTEM: &str = "role.system";
pub const ROLE_USER_BG: &str = "role.user.bg";
pub const ROLE_AGENT_BG: &str = "role.agent.bg";
pub const ROLE_TOOL_BG: &str = "role.tool.bg";
pub const ROLE_SYSTEM_BG: &str = "role.system.bg";
pub const STATUS_SUCCESS: &str = "status.success";
pub const STATUS_WARNING: &str = "status.warning";
pub const STATUS_ERROR: &str = "status.error";
pub const STATUS_INFO: &str = "status.info";
pub const HIGHLIGHT: &str = "highlight";
pub const SELECTED: &str = "selected";
pub const CHIP: &str = "chip";
pub const KBD: &str = "kbd";
pub const CODE: &str = "code";
pub const STRIPE_EVEN: &str = "stripe.even";
pub const STRIPE_ODD: &str = "stripe.odd";
pub const GRADIENT_TOP: &str = "gradient.top";
pub const GRADIENT_MID: &str = "gradient.mid";
pub const GRADIENT_BOT: &str = "gradient.bot";
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ThemeFlags {
pub no_color: bool,
pub no_icons: bool,
pub no_gradient: bool,
pub no_animations: bool,
pub a11y: bool,
}
impl ThemeFlags {
pub fn detect() -> Self {
Self {
no_color: std::env::var_os(ENV_NO_COLOR).is_some() || env_truthy(ENV_CASS_NO_COLOR),
no_icons: env_truthy(ENV_CASS_NO_ICONS),
no_gradient: env_truthy(ENV_CASS_NO_GRADIENT),
no_animations: env_truthy(ENV_CASS_DISABLE_ANIMATIONS) || env_is(ENV_CASS_ANIM, "0"),
a11y: env_truthy(ENV_CASS_A11Y),
}
}
pub fn custom(
no_color: bool,
no_icons: bool,
no_gradient: bool,
no_animations: bool,
a11y: bool,
) -> Self {
Self {
no_color,
no_icons,
no_gradient,
no_animations,
a11y,
}
}
pub fn all_enabled() -> Self {
Self {
no_color: false,
no_icons: false,
no_gradient: false,
no_animations: false,
a11y: false,
}
}
}
impl Default for ThemeFlags {
fn default() -> Self {
Self::all_enabled()
}
}
pub struct CassTheme {
pub preset: ThemePreset,
pub is_dark: bool,
pub theme: Theme,
pub styles: StyleSheet,
pub profile: ColorProfile,
pub color_cache: ColorCache,
pub flags: ThemeFlags,
}
impl CassTheme {
pub fn from_preset(preset: ThemePreset) -> Self {
let flags = ThemeFlags::detect();
let profile = if flags.no_color {
ColorProfile::Mono
} else {
ColorProfile::detect()
};
Self::with_options(preset, profile, flags)
}
pub fn with_options(preset: ThemePreset, profile: ColorProfile, flags: ThemeFlags) -> Self {
let palette = preset.to_palette();
let is_dark = !matches!(preset, ThemePreset::Daylight | ThemePreset::SolarizedLight);
let theme = build_ftui_theme(&palette, is_dark);
let styles = build_stylesheet(&palette, is_dark, &flags);
let color_cache = ColorCache::new(profile);
Self {
preset,
is_dark,
theme,
styles,
profile,
color_cache,
flags,
}
}
pub fn next_preset(&mut self) {
self.preset = self.preset.next();
self.rebuild();
}
pub fn prev_preset(&mut self) {
self.preset = self.preset.prev();
self.rebuild();
}
fn rebuild(&mut self) {
let palette = self.preset.to_palette();
self.is_dark = !matches!(
self.preset,
ThemePreset::Daylight | ThemePreset::SolarizedLight
);
self.theme = build_ftui_theme(&palette, self.is_dark);
self.styles = build_stylesheet(&palette, self.is_dark, &self.flags);
self.color_cache = ColorCache::new(self.profile);
}
pub fn style(&self, name: &str) -> Style {
self.styles.get_or_default(name)
}
pub fn compose(&self, names: &[&str]) -> Style {
self.styles.compose(names)
}
pub fn downgrade(&mut self, color: Color) -> Color {
color.downgrade(self.profile)
}
pub fn legacy_palette(&self) -> ThemePalette {
self.preset.to_palette()
}
pub fn show_icons(&self) -> bool {
!self.flags.no_icons
}
pub fn show_gradient(&self) -> bool {
!self.flags.no_gradient && self.profile.supports_true_color()
}
pub fn show_animations(&self) -> bool {
!self.flags.no_animations
}
pub fn a11y_mode(&self) -> bool {
self.flags.a11y
}
pub fn agent_icon(&self, agent: &str) -> &'static str {
if self.flags.no_icons {
""
} else {
ThemePalette::agent_icon(agent)
}
}
pub fn role_style(&self, role: &str) -> Style {
let id = match role.to_lowercase().as_str() {
"user" => style_ids::ROLE_USER,
"assistant" | "agent" => style_ids::ROLE_AGENT,
"tool" => style_ids::ROLE_TOOL,
"system" => style_ids::ROLE_SYSTEM,
_ => style_ids::TEXT_MUTED,
};
self.style(id)
}
pub fn role_bg_style(&self, role: &str) -> Style {
let id = match role.to_lowercase().as_str() {
"user" => style_ids::ROLE_USER_BG,
"assistant" | "agent" => style_ids::ROLE_AGENT_BG,
"tool" => style_ids::ROLE_TOOL_BG,
"system" => style_ids::ROLE_SYSTEM_BG,
_ => style_ids::BG_DEEP,
};
self.style(id)
}
pub fn agent_pane_style(&self, agent: &str) -> (Style, Style) {
let pane = ThemePalette::agent_pane(agent);
let bg = Style::new().bg(pane.bg);
let fg = Style::new().fg(pane.fg).bg(pane.bg);
(bg, fg)
}
pub fn stripe_style(&self, row_idx: usize) -> Style {
if row_idx.is_multiple_of(2) {
self.style(style_ids::STRIPE_EVEN)
} else {
self.style(style_ids::STRIPE_ODD)
}
}
}
impl Default for CassTheme {
fn default() -> Self {
Self::from_preset(ThemePreset::default())
}
}
fn build_ftui_theme(palette: &ThemePalette, is_dark: bool) -> Theme {
let c = |color: PackedRgba| -> Color { color.into() };
Theme::builder()
.primary(c(palette.accent))
.secondary(c(palette.accent_alt))
.accent(c(palette.accent))
.background(c(palette.bg))
.surface(c(palette.surface))
.overlay(c(palette.surface))
.text(c(palette.fg))
.text_muted(c(palette.hint))
.text_subtle(if is_dark {
c(legacy::colors::TEXT_DISABLED)
} else {
Color::rgb(180, 180, 190)
})
.success(c(legacy::colors::STATUS_SUCCESS))
.warning(c(legacy::colors::STATUS_WARNING))
.error(c(legacy::colors::STATUS_ERROR))
.info(c(legacy::colors::STATUS_INFO))
.border(c(palette.border))
.border_focused(c(legacy::colors::BORDER_FOCUS))
.selection_bg(if is_dark {
c(legacy::colors::BG_HIGHLIGHT)
} else {
Color::rgb(210, 215, 230)
})
.selection_fg(c(palette.fg))
.scrollbar_track(c(palette.surface))
.scrollbar_thumb(c(palette.border))
.build()
}
fn build_stylesheet(palette: &ThemePalette, is_dark: bool, flags: &ThemeFlags) -> StyleSheet {
let sheet = StyleSheet::new();
sheet.define(style_ids::TEXT_PRIMARY, Style::new().fg(palette.fg));
sheet.define(
style_ids::TEXT_SECONDARY,
Style::new().fg(if is_dark {
legacy::colors::TEXT_SECONDARY
} else {
palette.fg
}),
);
sheet.define(style_ids::TEXT_MUTED, Style::new().fg(palette.hint));
sheet.define(
style_ids::TEXT_DISABLED,
Style::new().fg(if is_dark {
legacy::colors::TEXT_DISABLED
} else {
PackedRgba::rgb(180, 180, 190)
}),
);
sheet.define(
style_ids::ACCENT_PRIMARY,
Style::new().fg(palette.accent).bold(),
);
sheet.define(
style_ids::ACCENT_SECONDARY,
Style::new().fg(palette.accent_alt),
);
sheet.define(
style_ids::ACCENT_TERTIARY,
Style::new().fg(if is_dark {
legacy::colors::ACCENT_TERTIARY
} else {
PackedRgba::rgb(0, 130, 200)
}),
);
sheet.define(style_ids::BG_DEEP, Style::new().bg(palette.bg));
sheet.define(style_ids::BG_SURFACE, Style::new().bg(palette.surface));
sheet.define(
style_ids::BG_HIGHLIGHT,
Style::new().bg(if is_dark {
legacy::colors::BG_HIGHLIGHT
} else {
PackedRgba::rgb(230, 232, 240)
}),
);
sheet.define(style_ids::BORDER, Style::new().fg(palette.border));
sheet.define(
style_ids::BORDER_FOCUS,
Style::new().fg(legacy::colors::BORDER_FOCUS),
);
sheet.define(
style_ids::BORDER_MINIMAL,
Style::new().fg(legacy::colors::BORDER_MINIMAL),
);
sheet.define(
style_ids::BORDER_EMPHASIZED,
Style::new().fg(legacy::colors::BORDER_EMPHASIZED),
);
sheet.define(style_ids::ROLE_USER, Style::new().fg(palette.user));
sheet.define(style_ids::ROLE_AGENT, Style::new().fg(palette.agent));
sheet.define(style_ids::ROLE_TOOL, Style::new().fg(palette.tool));
sheet.define(style_ids::ROLE_SYSTEM, Style::new().fg(palette.system));
sheet.define(
style_ids::ROLE_USER_BG,
Style::new().bg(legacy::colors::ROLE_USER_BG),
);
sheet.define(
style_ids::ROLE_AGENT_BG,
Style::new().bg(legacy::colors::ROLE_AGENT_BG),
);
sheet.define(
style_ids::ROLE_TOOL_BG,
Style::new().bg(legacy::colors::ROLE_TOOL_BG),
);
sheet.define(
style_ids::ROLE_SYSTEM_BG,
Style::new().bg(legacy::colors::ROLE_SYSTEM_BG),
);
sheet.define(
style_ids::STATUS_SUCCESS,
Style::new().fg(legacy::colors::STATUS_SUCCESS),
);
sheet.define(
style_ids::STATUS_WARNING,
Style::new().fg(legacy::colors::STATUS_WARNING),
);
sheet.define(
style_ids::STATUS_ERROR,
Style::new().fg(legacy::colors::STATUS_ERROR).bold(),
);
sheet.define(
style_ids::STATUS_INFO,
Style::new().fg(legacy::colors::STATUS_INFO),
);
sheet.define(
style_ids::HIGHLIGHT,
Style::new().fg(palette.bg).bg(palette.accent).bold(),
);
sheet.define(
style_ids::SELECTED,
Style::new()
.bg(if is_dark {
legacy::colors::BG_HIGHLIGHT
} else {
PackedRgba::rgb(220, 224, 236)
})
.bold(),
);
sheet.define(style_ids::CHIP, Style::new().fg(palette.accent_alt).bold());
sheet.define(style_ids::KBD, Style::new().fg(palette.accent).bold());
sheet.define(
style_ids::CODE,
Style::new()
.fg(if is_dark {
legacy::colors::TEXT_SECONDARY
} else {
palette.fg
})
.bg(palette.surface),
);
sheet.define(style_ids::STRIPE_EVEN, Style::new().bg(palette.stripe_even));
sheet.define(style_ids::STRIPE_ODD, Style::new().bg(palette.stripe_odd));
if !flags.no_gradient {
sheet.define(
style_ids::GRADIENT_TOP,
Style::new().bg(legacy::colors::GRADIENT_HEADER_TOP),
);
sheet.define(
style_ids::GRADIENT_MID,
Style::new().bg(legacy::colors::GRADIENT_HEADER_MID),
);
sheet.define(
style_ids::GRADIENT_BOT,
Style::new().bg(legacy::colors::GRADIENT_HEADER_BOT),
);
}
sheet
}
fn env_truthy(name: &str) -> bool {
match dotenvy::var(name) {
Ok(val) => {
let normalized = val.trim().to_ascii_lowercase();
!normalized.is_empty()
&& normalized != "0"
&& normalized != "false"
&& normalized != "off"
&& normalized != "no"
}
Err(_) => false,
}
}
fn env_is(name: &str, expected: &str) -> bool {
dotenvy::var(name).map(|v| v == expected).unwrap_or(false)
}
pub fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
let t = t.clamp(0.0, 1.0);
let result = f32::from(a) * (1.0 - t) + f32::from(b) * t;
result.round() as u8
}
pub fn lerp_color(from: Color, to: Color, progress: f32) -> Color {
let from_rgb = from.to_rgb();
let to_rgb = to.to_rgb();
Color::rgb(
lerp_u8(from_rgb.r, to_rgb.r, progress),
lerp_u8(from_rgb.g, to_rgb.g, progress),
lerp_u8(from_rgb.b, to_rgb.b, progress),
)
}
pub fn dim_color(color: Color, factor: f32) -> Color {
let rgb = color.to_rgb();
let factor = factor.clamp(0.0, 1.0);
Color::rgb(
(f32::from(rgb.r) * factor).round() as u8,
(f32::from(rgb.g) * factor).round() as u8,
(f32::from(rgb.b) * factor).round() as u8,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_creates_dark_theme() {
let theme = CassTheme::default();
assert_eq!(theme.preset, ThemePreset::TokyoNight);
assert!(theme.is_dark);
}
#[test]
fn all_presets_build_without_panic() {
let flags = ThemeFlags::all_enabled();
for preset in ThemePreset::all() {
let _ = CassTheme::with_options(*preset, ColorProfile::TrueColor, flags);
}
}
#[test]
fn style_sheet_has_core_styles() {
let theme = CassTheme::with_options(
ThemePreset::TokyoNight,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
assert!(theme.styles.contains(style_ids::TEXT_PRIMARY));
assert!(theme.styles.contains(style_ids::ROLE_USER));
assert!(theme.styles.contains(style_ids::ROLE_AGENT));
assert!(theme.styles.contains(style_ids::BORDER));
assert!(theme.styles.contains(style_ids::HIGHLIGHT));
assert!(theme.styles.contains(style_ids::STRIPE_EVEN));
assert!(theme.styles.contains(style_ids::STRIPE_ODD));
assert!(theme.styles.contains(style_ids::STATUS_ERROR));
}
#[test]
fn preset_cycling_wraps() {
let mut theme = CassTheme::with_options(
ThemePreset::Colorblind,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
theme.next_preset();
assert_eq!(theme.preset, ThemePreset::TokyoNight);
}
#[test]
fn no_color_forces_mono_profile() {
let flags = ThemeFlags::custom(true, false, false, false, false);
let theme =
CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
assert!(theme.flags.no_color);
}
#[test]
fn no_icons_suppresses_agent_icons() {
let flags = ThemeFlags::custom(false, true, false, false, false);
let theme =
CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
assert_eq!(theme.agent_icon("codex"), "");
assert_eq!(theme.agent_icon("claude_code"), "");
}
#[test]
fn icons_shown_by_default() {
let flags = ThemeFlags::all_enabled();
let theme =
CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
assert_eq!(theme.agent_icon("codex"), "\u{25c6}"); }
#[test]
fn role_styles_return_non_default() {
let theme = CassTheme::with_options(
ThemePreset::TokyoNight,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
let user_style = theme.role_style("user");
let agent_style = theme.role_style("assistant");
let tool_style = theme.role_style("tool");
let system_style = theme.role_style("system");
assert!(!user_style.is_empty());
assert!(!agent_style.is_empty());
assert!(!tool_style.is_empty());
assert!(!system_style.is_empty());
}
#[test]
fn stripe_alternates() {
let theme = CassTheme::with_options(
ThemePreset::TokyoNight,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
let even = theme.stripe_style(0);
let odd = theme.stripe_style(1);
assert_ne!(even, odd);
}
#[test]
fn light_theme_has_light_bg() {
let theme = CassTheme::with_options(
ThemePreset::Daylight,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
assert!(!theme.is_dark);
}
#[test]
fn high_contrast_has_core_styles() {
let theme = CassTheme::with_options(
ThemePreset::HighContrast,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
assert!(theme.styles.contains(style_ids::ROLE_USER));
assert!(theme.styles.contains(style_ids::STATUS_ERROR));
}
#[test]
fn compose_merges_styles() {
let theme = CassTheme::with_options(
ThemePreset::TokyoNight,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
let composed = theme.compose(&[style_ids::BG_DEEP, style_ids::TEXT_PRIMARY]);
assert!(!composed.is_empty());
}
#[test]
fn lerp_u8_extremes() {
assert_eq!(lerp_u8(0, 255, 0.0), 0);
assert_eq!(lerp_u8(0, 255, 1.0), 255);
assert_eq!(lerp_u8(0, 200, 0.5), 100);
}
#[test]
fn lerp_u8_clamps() {
assert_eq!(lerp_u8(0, 100, -1.0), 0);
assert_eq!(lerp_u8(0, 100, 2.0), 100);
}
#[test]
fn lerp_color_identity() {
let c = Color::rgb(100, 150, 200);
let result = lerp_color(c, c, 0.5);
assert_eq!(result, c);
}
#[test]
fn lerp_color_midpoint() {
let from = Color::rgb(0, 0, 0);
let to = Color::rgb(200, 100, 50);
let mid = lerp_color(from, to, 0.5);
let rgb = mid.to_rgb();
assert_eq!(rgb.r, 100);
assert_eq!(rgb.g, 50);
assert_eq!(rgb.b, 25);
}
#[test]
fn dim_color_half() {
let c = Color::rgb(200, 100, 50);
let dimmed = dim_color(c, 0.5);
let rgb = dimmed.to_rgb();
assert_eq!(rgb.r, 100);
assert_eq!(rgb.g, 50);
assert_eq!(rgb.b, 25);
}
#[test]
fn dim_color_zero_is_black() {
let c = Color::rgb(200, 100, 50);
let dimmed = dim_color(c, 0.0);
let rgb = dimmed.to_rgb();
assert_eq!(rgb.r, 0);
assert_eq!(rgb.g, 0);
assert_eq!(rgb.b, 0);
}
#[test]
fn packed_rgba_to_color_round_trips() {
let orig = PackedRgba::rgb(42, 84, 168);
let ftui_color: Color = orig.into();
let rgb = ftui_color.to_rgb();
assert_eq!(rgb.r, 42);
assert_eq!(rgb.g, 84);
assert_eq!(rgb.b, 168);
}
#[test]
fn no_gradient_skips_gradient_styles() {
let flags = ThemeFlags::custom(false, false, true, false, false);
let theme =
CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
assert!(!theme.styles.contains(style_ids::GRADIENT_TOP));
assert!(!theme.show_gradient());
}
#[test]
fn gradient_present_when_enabled() {
let flags = ThemeFlags::all_enabled();
let theme =
CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
assert!(theme.styles.contains(style_ids::GRADIENT_TOP));
assert!(theme.styles.contains(style_ids::GRADIENT_MID));
assert!(theme.styles.contains(style_ids::GRADIENT_BOT));
}
#[test]
fn a11y_mode_reports_correctly() {
let flags = ThemeFlags::custom(false, false, false, false, true);
let theme =
CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
assert!(theme.a11y_mode());
}
#[test]
fn theme_flags_default_all_enabled() {
let flags = ThemeFlags::default();
assert!(!flags.no_color);
assert!(!flags.no_icons);
assert!(!flags.no_gradient);
assert!(!flags.no_animations);
assert!(!flags.a11y);
}
#[test]
fn legacy_palette_matches_preset() {
let theme = CassTheme::with_options(
ThemePreset::Nord,
ColorProfile::TrueColor,
ThemeFlags::all_enabled(),
);
let palette = theme.legacy_palette();
assert_eq!(palette.bg, ThemePalette::nord().bg);
}
}