use std::sync::OnceLock;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders};
use crate::shared::config::Theme;
use crate::shared::osc11::Background;
#[cfg(test)]
use crate::shared::wrap;
static DETECTED_BACKGROUND: OnceLock<Option<Background>> = OnceLock::new();
pub fn set_detected_background(background: Option<Background>) {
let _ = DETECTED_BACKGROUND.set(background);
}
pub fn detected_background() -> Option<Background> {
DETECTED_BACKGROUND.get().copied().flatten()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GlyphSet {
pub border: BorderType,
pub assistant_icon: &'static str,
pub user_icon: &'static str,
pub system_icon: &'static str,
pub prompt: &'static str,
pub tool_head: &'static str,
pub tool_cont: &'static str,
pub collapsed: &'static str,
pub expanded: &'static str,
pub title_marker: &'static str,
pub chats_icon: &'static str,
pub settings_icon: &'static str,
pub help_icon: &'static str,
pub ok: &'static str,
pub failed: &'static str,
pub warn: &'static str,
pub status_connecting: &'static str,
pub status_off: &'static str,
pub busy: &'static str,
pub background: &'static str,
pub search: &'static str,
pub caret: &'static str,
pub add: &'static str,
pub spinner: &'static [char],
}
pub static UNICODE_GLYPHS: GlyphSet = GlyphSet {
border: BorderType::Rounded,
assistant_icon: "✦",
user_icon: "❯",
system_icon: "§",
prompt: "❯ ",
tool_head: "⚒ ",
tool_cont: " ",
collapsed: "▸",
expanded: "▾",
title_marker: "◆",
chats_icon: "▤",
settings_icon: "⚙ ",
help_icon: "⌨ ",
ok: "✓",
failed: "✗",
warn: "⚠",
status_connecting: "◐",
status_off: "✕",
busy: "⟳",
background: "✻",
search: "⌕",
caret: "▏",
add: "➕",
spinner: &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
};
pub static COMPAT_GLYPHS: GlyphSet = GlyphSet {
border: BorderType::Plain,
assistant_icon: "*",
user_icon: ">",
system_icon: "§",
prompt: "> ",
tool_head: "# ",
tool_cont: " ",
collapsed: "►",
expanded: "▼",
title_marker: "♦",
chats_icon: "≡",
settings_icon: "# ",
help_icon: "# ",
ok: "√",
failed: "×",
warn: "!",
status_connecting: "○",
status_off: "×",
busy: "»",
background: "*",
search: "?",
caret: "│",
add: "+",
spinner: &['|', '/', '-', '\\'],
};
#[cfg(test)]
pub const SHOT_CANVAS_DARK: Color = Color::Rgb(15, 17, 21);
#[cfg(test)]
pub const SHOT_CANVAS_LIGHT: Color = Color::Rgb(250, 250, 252);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Palette {
pub user: Color,
pub assistant: Color,
pub tool: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub accent: Color,
pub user_soft: Color,
pub assistant_soft: Color,
pub tool_soft: Color,
pub text: Color,
pub muted: Color,
pub border: Color,
pub border_focus: Color,
pub keycap_fg: Color,
pub keycap_bg: Color,
pub keycap_danger: Color,
pub dark: bool,
pub compat: bool,
}
impl Palette {
pub fn for_theme(theme: Theme) -> Self {
match theme {
Theme::Auto => Self::auto(),
Theme::Dark => Self::dark(),
Theme::Light => Self::light(),
}
}
fn auto() -> Self {
Self::auto_with(detected_background())
}
pub(crate) fn auto_with(background: Option<Background>) -> Self {
let light = background == Some(Background::Light);
let reference = if light { Self::light() } else { Self::dark() };
Self {
user: Color::Cyan,
assistant: Color::Green,
tool: Color::Yellow,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
accent: Color::Magenta,
user_soft: Color::LightCyan,
assistant_soft: Color::LightGreen,
tool_soft: Color::LightYellow,
text: Color::Reset,
muted: Color::DarkGray,
border: Color::DarkGray,
border_focus: Color::Gray,
keycap_fg: reference.keycap_fg,
keycap_bg: reference.keycap_bg,
keycap_danger: reference.keycap_danger,
dark: !light,
compat: false,
}
}
fn dark() -> Self {
Self {
user: Color::Rgb(121, 169, 219), assistant: Color::Rgb(111, 192, 130), tool: Color::Rgb(220, 175, 97), success: Color::Rgb(111, 192, 130), warning: Color::Rgb(220, 175, 97), error: Color::Rgb(223, 105, 92), accent: Color::Rgb(220, 175, 97), user_soft: Color::Rgb(144, 188, 233), assistant_soft: Color::Rgb(164, 209, 172), tool_soft: Color::Rgb(230, 201, 154), text: Color::Rgb(201, 204, 210), muted: Color::Rgb(126, 132, 139), border: Color::Rgb(54, 58, 66), border_focus: Color::Rgb(110, 117, 128), keycap_fg: Color::Rgb(132, 138, 146), keycap_bg: Color::Rgb(33, 36, 42), keycap_danger: Color::Rgb(232, 116, 104), dark: true,
compat: false,
}
}
fn light() -> Self {
Self {
user: Color::Blue,
assistant: Color::Rgb(0, 128, 0),
tool: Color::Rgb(160, 100, 0),
success: Color::Rgb(0, 128, 0),
warning: Color::Rgb(160, 100, 0),
error: Color::Rgb(180, 0, 0),
accent: Color::Rgb(140, 0, 140),
user_soft: Color::Rgb(40, 80, 170),
assistant_soft: Color::Rgb(0, 110, 0),
tool_soft: Color::Rgb(150, 95, 0),
text: Color::Rgb(30, 32, 36),
muted: Color::Rgb(110, 116, 124),
border: Color::Rgb(190, 193, 198),
border_focus: Color::Rgb(120, 124, 130),
keycap_fg: Color::Rgb(74, 78, 84), keycap_bg: Color::Rgb(222, 224, 228),
keycap_danger: Color::Rgb(178, 34, 34),
dark: false,
compat: false,
}
}
pub fn with_compat(mut self, compat: bool) -> Self {
self.compat = compat;
self
}
pub fn glyphs(&self) -> &'static GlyphSet {
if self.compat {
&COMPAT_GLYPHS
} else {
&UNICODE_GLYPHS
}
}
pub fn success_style(&self) -> Style {
Style::new().fg(self.success)
}
pub fn accent_style(&self) -> Style {
Style::new().fg(self.accent)
}
pub fn muted_style(&self) -> Style {
Style::new().fg(self.muted)
}
pub fn border_style(&self, focused: bool) -> Style {
Style::new().fg(if focused {
self.border_focus
} else {
self.border
})
}
pub fn panel(&self, title: impl Into<String>, focused: bool) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(self.glyphs().border)
.border_style(self.border_style(focused))
.title(Span::styled(
format!(" {} ", title.into()),
Style::new().fg(self.text),
))
}
pub fn keycap(&self, label: impl Into<String>) -> Span<'static> {
Span::styled(
format!(" {} ", label.into()),
Style::new().fg(self.keycap_fg).bg(self.keycap_bg),
)
}
pub fn hint(&self, key: &str, desc: &str) -> Vec<Span<'static>> {
vec![
self.keycap(key),
Span::styled(format!(" {desc}"), self.muted_style()),
]
}
pub fn hint_highlight_value(&self, key: &str, desc: &str, color: Color) -> Vec<Span<'static>> {
let mut spans = vec![self.keycap(key)];
match desc.split_once(':') {
Some((label, value)) => {
spans.push(Span::styled(format!(" {label}: "), self.muted_style()));
spans.push(Span::styled(
value.trim().to_string(),
Style::new().fg(color),
));
}
None => spans.push(Span::styled(format!(" {desc}"), Style::new().fg(color))),
}
spans
}
pub fn hint_marked(&self, key: &str, desc: &str, danger: bool) -> Vec<Span<'static>> {
if !danger {
return self.hint(key, desc);
}
vec![
Span::styled(
format!(" {key} "),
Style::new().fg(self.keycap_danger).bg(self.keycap_bg),
),
Span::styled(format!(" {desc}"), self.muted_style()),
]
}
}
#[cfg(test)]
fn str_width(s: &str) -> usize {
wrap::display_width(&s.chars().collect::<Vec<_>>())
}
impl Default for Palette {
fn default() -> Self {
Self::auto()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_uses_named_ansi_colors() {
let p = Palette::for_theme(Theme::Auto);
assert_eq!(p.user, Color::Cyan);
assert_eq!(p.error, Color::Red);
}
#[test]
fn dark_and_light_differ_from_auto() {
let auto = Palette::for_theme(Theme::Auto);
assert_ne!(Palette::for_theme(Theme::Dark), auto);
assert_ne!(Palette::for_theme(Theme::Light), auto);
}
#[test]
fn default_is_auto() {
assert_eq!(Palette::default(), Palette::for_theme(Theme::Auto));
}
#[test]
fn dark_flag_follows_theme() {
assert!(Palette::for_theme(Theme::Auto).dark);
assert!(Palette::for_theme(Theme::Dark).dark);
assert!(!Palette::for_theme(Theme::Light).dark);
}
#[test]
fn auto_undetected_is_byte_for_byte_the_old_palette() {
let auto = Palette::auto_with(None);
let dark = Palette::dark();
assert!(auto.dark);
assert_eq!(auto.keycap_fg, dark.keycap_fg);
assert_eq!(auto.keycap_bg, dark.keycap_bg);
assert_eq!(auto.keycap_danger, dark.keycap_danger);
assert_eq!(auto.user, Color::Cyan);
assert_eq!(auto.text, Color::Reset);
}
#[test]
fn auto_detected_light_borrows_the_light_keycaps() {
let auto = Palette::auto_with(Some(Background::Light));
let light = Palette::light();
assert!(!auto.dark, "a light background must not claim to be dark");
assert_eq!(auto.keycap_fg, light.keycap_fg);
assert_eq!(auto.keycap_bg, light.keycap_bg);
assert_eq!(auto.keycap_danger, light.keycap_danger);
}
#[test]
fn auto_detected_light_keeps_the_named_ansi_roles() {
let auto = Palette::auto_with(Some(Background::Light));
assert_eq!(auto.user, Color::Cyan);
assert_eq!(auto.assistant, Color::Green);
assert_eq!(auto.tool, Color::Yellow);
assert_eq!(auto.text, Color::Reset);
assert_ne!(
auto,
Palette::light(),
"Auto on a light terminal must not collapse into Light"
);
}
#[test]
fn auto_detected_dark_is_the_fallback_palette() {
assert_eq!(
Palette::auto_with(Some(Background::Dark)),
Palette::auto_with(None)
);
}
#[test]
fn keycap_and_hint_carry_label() {
let p = Palette::default();
let cap = p.keycap("Ctrl+N");
assert!(cap.content.contains("Ctrl+N"));
let hint = p.hint("Enter", "отправить");
let joined: String = hint.iter().map(|s| s.content.as_ref()).collect();
assert!(joined.contains("Enter") && joined.contains("отправить"));
}
#[test]
fn glyphs_follow_compat_flag() {
let p = Palette::default();
assert!(!p.compat);
assert_eq!(p.glyphs(), &UNICODE_GLYPHS);
assert_eq!(p.glyphs().border, BorderType::Rounded);
let c = p.with_compat(true);
assert_eq!(c.glyphs(), &COMPAT_GLYPHS);
assert_eq!(c.glyphs().border, BorderType::Plain);
}
#[test]
fn compat_glyphs_avoid_rare_symbols() {
let banned: Vec<char> = "✦❯⚒▸▾◆▤⚙⌨✓✗⚠◐✕⟳✻⌕▏➕".chars().collect();
let g = &COMPAT_GLYPHS;
let all = [
g.assistant_icon,
g.user_icon,
g.system_icon,
g.prompt,
g.tool_head,
g.tool_cont,
g.collapsed,
g.expanded,
g.title_marker,
g.chats_icon,
g.settings_icon,
g.help_icon,
g.ok,
g.failed,
g.warn,
g.status_connecting,
g.status_off,
g.busy,
g.background,
g.search,
g.caret,
g.add,
];
for s in all {
for ch in s.chars() {
assert!(
!banned.contains(&ch),
"rare character in compat set: {ch:?}"
);
}
}
assert!(g.spinner.iter().all(|c| c.is_ascii()), "{:?}", g.spinner);
}
#[test]
fn tool_head_and_cont_widths_match_in_both_sets() {
for g in [&UNICODE_GLYPHS, &COMPAT_GLYPHS] {
assert_eq!(str_width(g.tool_head), str_width(g.tool_cont));
}
assert_eq!(str_width(UNICODE_GLYPHS.prompt), 2);
assert_eq!(str_width(COMPAT_GLYPHS.prompt), 2);
}
}