use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use ratatui::{
style::{Color, Modifier, Style},
text::Line,
};
use super::{
markdown::HeadingLevel,
theme_scheme::{
self, is_terminal_theme_id, normalize_theme_id, resolve_fixed_scheme, ColorScheme, Rgb,
TERMINAL_THEME_ID,
},
theme_terminal::{query_terminal_palette, AnsiColor, TerminalPalette},
};
#[path = "theme_diff.rs"]
mod theme_diff;
const USER_BACKGROUND_ALPHA: f32 = 0.10;
const NEUTRAL_TOOL_BACKGROUND_ALPHA: f32 = 0.10;
const HOVER_LIFT_ALPHA: f32 = 0.35;
const LIGHT_BACKGROUND_LUMINANCE: f32 = 0.55;
const DIM_MAX_LUMINANCE_ON_DARK: f32 = 0.75;
const DIM_MIN_LUMINANCE_ON_DARK: f32 = 0.12;
const DIM_MAX_LUMINANCE_ON_LIGHT: f32 = 0.45;
const DIM_CONTRAST_MARGIN: f32 = 0.08;
const ROLE_INK_MARGIN: f32 = 0.22;
static TERMINAL_SAMPLE: OnceLock<TerminalPalette> = OnceLock::new();
static THEME_STATE: Mutex<ThemeState> = Mutex::new(ThemeState::new());
#[cfg(test)]
pub(super) fn theme_test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|error| error.into_inner())
}
#[derive(Clone, Debug)]
struct ThemeState {
committed_id: String,
active_id: String,
active_scheme: Option<ColorScheme>,
generation: u64,
palette: Option<Palette>,
picker_catalog: Option<HashMap<String, ColorScheme>>,
}
impl ThemeState {
const fn new() -> Self {
Self {
committed_id: String::new(),
active_id: String::new(),
active_scheme: None,
generation: 0,
palette: None,
picker_catalog: None,
}
}
}
impl Rgb {
fn color(self) -> Color {
Color::Rgb(self.red, self.green, self.blue)
}
fn is_usable_dim(self, background_luminance: f32) -> bool {
let luminance = self.luminance();
if is_light_background(background_luminance) {
luminance + DIM_CONTRAST_MARGIN < background_luminance
&& luminance < DIM_MAX_LUMINANCE_ON_LIGHT
} else {
(DIM_MIN_LUMINANCE_ON_DARK..=DIM_MAX_LUMINANCE_ON_DARK).contains(&luminance)
&& luminance >= background_luminance + DIM_CONTRAST_MARGIN
}
}
}
impl TerminalPalette {
fn blended_background(&self, color: AnsiColor, alpha: f32) -> Option<BlockColor> {
self.ansi.get(&color).map(|ansi| {
let rgb = self.background.blend_toward(*ansi, alpha);
BlockColor::from_rgb(rgb)
})
}
fn dim_foreground(&self) -> Color {
let background_luminance = self.background.luminance();
let fallback = if is_light_background(background_luminance) {
Color::Black
} else {
Color::DarkGray
};
self.ansi
.get(&AnsiColor::BrightBlack)
.copied()
.filter(|rgb| rgb.is_usable_dim(background_luminance))
.map_or(fallback, Rgb::color)
}
}
fn scheme_dim_foreground(scheme: &ColorScheme) -> Color {
let background = scheme.background;
let background_luminance = background.luminance();
let candidates = [
scheme_ansi(scheme, AnsiColor::BrightBlack),
scheme.ansi[0], scheme.foreground,
];
for candidate in candidates {
if candidate.is_usable_dim(background_luminance) {
return candidate.color();
}
}
background.blend_toward(scheme.foreground, 0.55).color()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct BlockColor {
color: Color,
rgb: Option<Rgb>,
}
impl BlockColor {
fn from_rgb(rgb: Rgb) -> Self {
Self {
color: rgb.color(),
rgb: Some(rgb),
}
}
const fn from_color(color: Color) -> Self {
Self { color, rgb: None }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Palette {
text: Option<Color>,
surface: Option<Color>,
dim: Color,
accent: Color,
success: Color,
warning: Color,
error: Color,
skill: Color,
user_background: BlockColor,
neutral_tool_background: BlockColor,
diff_add_wash: Option<BlockColor>,
diff_del_wash: Option<BlockColor>,
}
impl Palette {
fn current() -> Self {
let mut state = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(palette) = state.palette {
return palette;
}
let palette = match state.active_scheme.as_ref() {
Some(scheme) => Self::from_scheme(scheme),
None => Self::from_terminal(TERMINAL_SAMPLE.get()),
};
state.palette = Some(palette);
palette
}
fn from_terminal(terminal: Option<&TerminalPalette>) -> Self {
let surface = terminal.map(|palette| palette.background);
let (diff_add_wash, diff_del_wash) = theme_diff::terminal_diff_washes(terminal);
Self {
text: None,
surface: None,
dim: terminal.map_or(Color::DarkGray, TerminalPalette::dim_foreground),
accent: role_ink(sampled_or_named(terminal, AnsiColor::Cyan), surface),
success: role_ink(sampled_or_named(terminal, AnsiColor::Green), surface),
warning: role_ink(sampled_or_named(terminal, AnsiColor::Yellow), surface),
error: role_ink(sampled_or_named(terminal, AnsiColor::Red), surface),
skill: role_ink(sampled_or_named(terminal, AnsiColor::Magenta), surface),
user_background: blended_or_fallback(
terminal,
AnsiColor::White,
USER_BACKGROUND_ALPHA,
BlockColor::from_color(Color::DarkGray),
),
neutral_tool_background: blended_or_fallback(
terminal,
AnsiColor::White,
NEUTRAL_TOOL_BACKGROUND_ALPHA,
BlockColor::from_color(Color::DarkGray),
),
diff_add_wash,
diff_del_wash,
}
}
fn from_scheme(scheme: &ColorScheme) -> Self {
let panel = scheme_panel_background(scheme);
let surface = scheme.background;
let (diff_add_wash, diff_del_wash) = theme_diff::scheme_diff_washes(scheme);
Self {
text: Some(scheme.foreground.color()),
surface: Some(scheme.background.color()),
dim: scheme_dim_foreground(scheme),
accent: role_ink(scheme_ansi(scheme, AnsiColor::Cyan).color(), Some(surface)),
success: role_ink(scheme_ansi(scheme, AnsiColor::Green).color(), Some(surface)),
warning: role_ink(
scheme_ansi(scheme, AnsiColor::Yellow).color(),
Some(surface),
),
error: role_ink(scheme_ansi(scheme, AnsiColor::Red).color(), Some(surface)),
skill: role_ink(
scheme_ansi(scheme, AnsiColor::Magenta).color(),
Some(surface),
),
user_background: panel,
neutral_tool_background: panel,
diff_add_wash,
diff_del_wash,
}
}
}
fn scheme_panel_background(scheme: &ColorScheme) -> BlockColor {
let background = scheme.background;
let wash = if is_light_background(background.luminance()) {
scheme.ansi[0] } else {
scheme_ansi(scheme, AnsiColor::White)
};
BlockColor::from_rgb(background.blend_toward(wash, USER_BACKGROUND_ALPHA))
}
fn scheme_ansi(scheme: &ColorScheme, color: AnsiColor) -> Rgb {
scheme.ansi[color.index() as usize]
}
fn sampled_or_named(terminal: Option<&TerminalPalette>, color: AnsiColor) -> Color {
terminal
.and_then(|palette| palette.ansi.get(&color).copied())
.map(Rgb::color)
.unwrap_or_else(|| color.color())
}
fn role_ink(ink: Color, surface: Option<Rgb>) -> Color {
let Color::Rgb(red, green, blue) = ink else {
return ink;
};
let Some(surface) = surface else {
return ink;
};
let ink = Rgb::new(red, green, blue);
let surface_luminance = surface.luminance();
let ink_luminance = ink.luminance();
if is_light_background(surface_luminance) {
if ink_luminance + ROLE_INK_MARGIN <= surface_luminance {
return ink.color();
}
return pull_ink_until(ink, Rgb::new(0, 0, 0), |candidate| {
candidate.luminance() + ROLE_INK_MARGIN <= surface_luminance
});
}
if ink_luminance > surface_luminance {
return ink.color();
}
pull_ink_until(ink, Rgb::new(255, 255, 255), |candidate| {
candidate.luminance() > surface_luminance
})
}
fn pull_ink_until(start: Rgb, target: Rgb, acceptable: impl Fn(Rgb) -> bool) -> Color {
if acceptable(start) {
return start.color();
}
let mut best = start.blend_toward(target, 0.95);
for alpha in [0.25_f32, 0.4, 0.55, 0.7, 0.85, 0.95] {
let candidate = start.blend_toward(target, alpha);
if acceptable(candidate) {
return candidate.color();
}
best = candidate;
}
best.color()
}
fn active_ansi_color(color: AnsiColor) -> Color {
let state = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(scheme) = state.active_scheme.as_ref() {
return scheme_ansi(scheme, color).color();
}
sampled_or_named(TERMINAL_SAMPLE.get(), color)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SyntaxRole {
Comment,
String,
Constant,
Keyword,
Function,
Type,
}
pub(super) struct Theme;
impl Theme {
pub(super) fn initialize_from_terminal() {
if let Some(palette) = query_terminal_palette() {
let _ = TERMINAL_SAMPLE.set(palette);
THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
.palette = None;
}
}
pub(super) fn set_picker_catalog(entries: &[theme_scheme::ThemeEntry]) {
let mut catalog = HashMap::new();
for entry in entries {
if let theme_scheme::ThemeEntry::Fixed(scheme) = entry {
catalog.insert(scheme.id.clone(), scheme.clone());
}
}
THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
.picker_catalog = Some(catalog);
}
pub(super) fn clear_picker_catalog() {
THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
.picker_catalog = None;
}
pub(super) fn apply_committed(id: &str) {
apply_theme_id(id, true);
}
pub(super) fn preview(id: &str) {
apply_theme_id(id, false);
}
pub(super) fn cancel_preview() {
let id = {
let state = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner());
if state.active_id == state.committed_id {
return;
}
normalize_theme_id(&state.committed_id)
};
apply_theme_id(&id, false);
}
pub(super) fn committed_id() -> String {
let id = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
.committed_id
.clone();
normalize_theme_id(&id)
}
pub(super) fn active_id() -> String {
let id = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
.active_id
.clone();
normalize_theme_id(&id)
}
pub(super) fn generation() -> u64 {
THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner())
.generation
}
pub(super) fn surface() -> Style {
let palette = Palette::current();
let mut style = Style::default().remove_modifier(Modifier::UNDERLINED);
if let Some(fg) = palette.text {
style = style.fg(fg);
}
if let Some(bg) = palette.surface {
style = style.bg(bg);
}
style
}
pub(super) fn contrasting_ink_on(background: Color) -> Color {
match background {
Color::Rgb(red, green, blue) => block_foreground(Some(Rgb::new(red, green, blue))),
_ if is_light_surface(background) => Color::Black,
_ => Color::White,
}
}
pub(super) fn text() -> Style {
let mut style = Style::default().remove_modifier(Modifier::UNDERLINED);
if let Some(fg) = Palette::current().text {
style = style.fg(fg);
}
style
}
pub(super) fn text_strong() -> Style {
Self::text().add_modifier(Modifier::BOLD)
}
pub(super) fn dim() -> Style {
Style::default().fg(Palette::current().dim)
}
pub(super) fn dim_italic() -> Style {
Self::dim().add_modifier(Modifier::ITALIC)
}
pub(super) fn accent() -> Style {
Style::default().fg(Palette::current().accent)
}
pub(super) fn brand() -> Style {
Self::accent().add_modifier(Modifier::BOLD)
}
pub(super) fn activity_rail() -> Style {
let background = Palette::current().neutral_tool_background;
Style::reset()
.fg(block_foreground(background.rgb))
.bg(background.color)
}
pub(super) fn jump_to_bottom() -> Style {
Self::activity_rail().fg(Palette::current().accent)
}
pub(super) fn jump_to_bottom_attention() -> Style {
Self::jump_to_bottom().add_modifier(Modifier::BOLD)
}
pub(super) fn jump_to_bottom_shortcut() -> Style {
Self::activity_rail().fg(Palette::current().dim)
}
pub(super) fn subagent_row(state: super::subagent_panel::SubagentRowState) -> Style {
use super::subagent_panel::SubagentRowState;
match state {
SubagentRowState::Idle => Self::activity_rail(),
SubagentRowState::Hovered => Self::activity_rail().fg(Palette::current().accent),
SubagentRowState::Pressed => {
let accent = Palette::current().accent;
Style::default()
.fg(Self::contrasting_ink_on(accent))
.bg(accent)
.add_modifier(Modifier::BOLD)
}
}
}
pub(super) fn success() -> Style {
Style::default()
.fg(Palette::current().success)
.add_modifier(Modifier::BOLD)
}
pub(super) fn warning() -> Style {
Style::default()
.fg(Palette::current().warning)
.add_modifier(Modifier::BOLD)
}
pub(super) fn error() -> Style {
Style::default()
.fg(Palette::current().error)
.add_modifier(Modifier::BOLD)
}
pub(super) fn input_prompt() -> Style {
Style::default()
.fg(Palette::current().accent)
.add_modifier(Modifier::BOLD)
}
pub(super) fn user_message() -> Style {
Self::dim_block(Palette::current().user_background)
}
pub(super) fn reasoning_output(lines: &mut [Line<'static>]) {
let reasoning_style = Self::dim();
for line in lines {
line.style = reasoning_style
.patch(line.style)
.remove_modifier(Modifier::DIM);
for span in &mut line.spans {
span.style = reasoning_style
.patch(span.style)
.remove_modifier(Modifier::DIM);
}
}
}
pub(super) fn reasoning_input_border(level: rho_providers::reasoning::ReasoningLevel) -> Style {
let color = match level {
rho_providers::reasoning::ReasoningLevel::Off => return Theme::dim(),
rho_providers::reasoning::ReasoningLevel::Minimal => active_ansi_color(AnsiColor::Blue),
rho_providers::reasoning::ReasoningLevel::Low => active_ansi_color(AnsiColor::Cyan),
rho_providers::reasoning::ReasoningLevel::Medium => active_ansi_color(AnsiColor::Green),
rho_providers::reasoning::ReasoningLevel::High => active_ansi_color(AnsiColor::Yellow),
rho_providers::reasoning::ReasoningLevel::Xhigh => {
active_ansi_color(AnsiColor::Magenta)
}
rho_providers::reasoning::ReasoningLevel::Max => active_ansi_color(AnsiColor::Red),
};
Style::default().fg(color)
}
pub(super) fn markdown_heading(level: HeadingLevel) -> Style {
let color = match level {
HeadingLevel::H1 => active_ansi_color(AnsiColor::Magenta),
HeadingLevel::H2 => active_ansi_color(AnsiColor::Blue),
HeadingLevel::H3 => active_ansi_color(AnsiColor::Cyan),
HeadingLevel::H4 => active_ansi_color(AnsiColor::Green),
HeadingLevel::H5 => active_ansi_color(AnsiColor::Yellow),
HeadingLevel::H6 => active_ansi_color(AnsiColor::BrightBlack),
};
let style = Style::default()
.fg(color)
.remove_modifier(Modifier::UNDERLINED);
match level {
HeadingLevel::H1 | HeadingLevel::H2 | HeadingLevel::H3 => {
style.add_modifier(Modifier::BOLD)
}
HeadingLevel::H4 | HeadingLevel::H5 | HeadingLevel::H6 => style,
}
}
pub(super) fn markdown_inline_code() -> Style {
Style::default()
.fg(Palette::current().warning)
.remove_modifier(Modifier::UNDERLINED)
}
pub(super) fn code_text() -> Style {
Self::text()
}
pub(super) fn syntax(role: SyntaxRole) -> Style {
let color = match role {
SyntaxRole::Comment => active_ansi_color(AnsiColor::BrightBlack),
SyntaxRole::String => active_ansi_color(AnsiColor::Green),
SyntaxRole::Constant => active_ansi_color(AnsiColor::Magenta),
SyntaxRole::Keyword => active_ansi_color(AnsiColor::Blue),
SyntaxRole::Function => active_ansi_color(AnsiColor::Yellow),
SyntaxRole::Type => active_ansi_color(AnsiColor::Cyan),
};
Style::default()
.fg(color)
.remove_modifier(Modifier::UNDERLINED)
}
pub(super) fn search_match(base: Style) -> Style {
let palette = Palette::current();
let bg = palette.warning;
base.bg(bg)
.fg(Self::contrasting_ink_on(bg))
.add_modifier(Modifier::BOLD)
.remove_modifier(Modifier::UNDERLINED)
}
pub(super) fn markdown_code_copy_button(hovered: bool) -> Style {
let palette = Palette::current();
if hovered {
Style::default()
.fg(Self::contrasting_ink_on(palette.accent))
.bg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Self::dim_block(palette.neutral_tool_background).add_modifier(Modifier::BOLD)
}
}
pub(super) fn hover_lifted(fg: Color) -> Option<Color> {
let Color::Rgb(red, green, blue) = fg else {
return None;
};
let palette = Palette::current();
let dark_surface = palette
.surface
.is_none_or(|surface| !is_light_surface(surface));
let target = if dark_surface {
Rgb::new(255, 255, 255)
} else {
Rgb::new(0, 0, 0)
};
Some(
Rgb::new(red, green, blue)
.blend_toward(target, HOVER_LIFT_ALPHA)
.color(),
)
}
pub(super) fn markdown_bold() -> Style {
Self::text()
.add_modifier(Modifier::BOLD)
.remove_modifier(Modifier::UNDERLINED)
}
pub(super) fn markdown_italic() -> Style {
Self::text()
.add_modifier(Modifier::ITALIC)
.remove_modifier(Modifier::UNDERLINED)
}
pub(super) fn markdown_link() -> Style {
Style::default()
.fg(Palette::current().accent)
.add_modifier(Modifier::UNDERLINED)
}
pub(super) fn command_block() -> Style {
Self::dim_block(Palette::current().neutral_tool_background)
}
pub(super) fn tool_marker(status: rho_tools::tool_card::ToolStatus) -> Style {
use rho_tools::tool_card::ToolStatus;
match status {
ToolStatus::Running => Self::accent(),
ToolStatus::Ok => Self::success(),
ToolStatus::Error => Self::error(),
ToolStatus::Interrupted => Self::warning(),
}
}
pub(super) fn tool_verb(family: rho_tools::tool_card::ToolFamily) -> Style {
use rho_tools::tool_card::ToolFamily;
let palette = Palette::current();
match family {
ToolFamily::FileCommand | ToolFamily::FileDiff => Style::default().fg(palette.success),
ToolFamily::Web => Style::default().fg(active_ansi_color(AnsiColor::Blue)),
ToolFamily::Skill => Style::default().fg(palette.skill),
ToolFamily::Form => Style::default().fg(palette.warning),
ToolFamily::Agent => Self::text(),
ToolFamily::Default => Self::dim(),
}
}
pub(super) fn tool_primary() -> Style {
Self::text()
}
pub(super) fn tool_tree() -> Style {
Self::dim()
}
pub(super) fn tool_meta() -> Style {
Self::dim()
}
pub(super) fn tool_path() -> Style {
Self::dim()
}
pub(super) fn tool_stat_add() -> Style {
Style::default().fg(Palette::current().success)
}
pub(super) fn tool_stat_del() -> Style {
Style::default().fg(Palette::current().error)
}
pub(super) fn tool_diff_gutter() -> Style {
Self::dim()
}
pub(super) fn tool_exit(status: rho_tools::tool_card::ToolStatus) -> Style {
use rho_tools::tool_card::ToolStatus;
match status {
ToolStatus::Ok | ToolStatus::Running => Self::success(),
ToolStatus::Error | ToolStatus::Interrupted => Self::error(),
}
}
pub(super) fn tool_error_text() -> Style {
Self::error()
}
pub(super) fn tool_card_padding() -> Style {
Self::text()
}
fn dim_block(background: BlockColor) -> Style {
Style::default()
.fg(block_foreground(background.rgb))
.bg(background.color)
}
}
fn apply_theme_id(id: &str, commit: bool) {
let id = normalize_theme_id(id);
let mut state = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner());
let scheme = if is_terminal_theme_id(&id) {
None
} else if let Some(scheme) = state
.picker_catalog
.as_ref()
.and_then(|catalog| catalog.get(&id).cloned())
{
Some(scheme)
} else {
drop(state);
let scheme = resolve_fixed_scheme(&id);
state = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner());
scheme
};
let resolved_id = if scheme.is_none() {
TERMINAL_THEME_ID.to_string()
} else {
id
};
let changed = state.active_id != resolved_id
|| state.active_scheme.as_ref().map(|s| s.id.as_str())
!= scheme.as_ref().map(|s| s.id.as_str());
if commit {
state.committed_id = resolved_id.clone();
}
if changed {
state.active_id = resolved_id;
state.active_scheme = scheme;
state.generation = state.generation.saturating_add(1);
state.palette = None;
}
}
pub(super) use theme_scheme::{
list_themes, theme_display_name, ThemeEntry, TERMINAL_THEME_ID as THEME_TERMINAL_ID,
};
fn block_foreground(background: Option<Rgb>) -> Color {
let on_light = background.is_some_and(|rgb| is_light_background(rgb.luminance()));
let state = THEME_STATE
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(scheme) = state.active_scheme.as_ref() {
let fg = scheme.foreground;
let bg = scheme.background;
return if on_light {
if fg.luminance() <= bg.luminance() {
fg.color()
} else {
bg.color()
}
} else if fg.luminance() >= bg.luminance() {
fg.color()
} else {
bg.color()
};
}
if on_light {
Color::Black
} else {
Color::White
}
}
fn is_light_background(luminance: f32) -> bool {
luminance > LIGHT_BACKGROUND_LUMINANCE
}
fn is_light_surface(color: Color) -> bool {
match color {
Color::Rgb(red, green, blue) => is_light_background(Rgb::new(red, green, blue).luminance()),
Color::White | Color::Gray | Color::Yellow => true,
_ => false,
}
}
fn blended_or_fallback(
terminal: Option<&TerminalPalette>,
color: AnsiColor,
alpha: f32,
fallback: BlockColor,
) -> BlockColor {
optional_blended(terminal, color, alpha).unwrap_or(fallback)
}
fn optional_blended(
terminal: Option<&TerminalPalette>,
color: AnsiColor,
alpha: f32,
) -> Option<BlockColor> {
terminal.and_then(|palette| palette.blended_background(color, alpha))
}
#[cfg(test)]
#[path = "theme_tests.rs"]
mod tests;