use std::collections::HashSet;
use std::sync::{LazyLock, Mutex, OnceLock};
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders};
use crate::shared::config::{InterfaceSettings, Theme, ThemeMode};
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: &['|', '/', '-', '\\'],
};
pub const CANVAS_DARK: Color = Color::Rgb(15, 17, 21);
pub const CANVAS_LIGHT: Color = Color::Rgb(250, 250, 252);
pub const FULL_THEMES: [&str; 2] = ["dark", "light"];
pub const MONO_MARK: Color = Color::Indexed(255);
pub const SELECTED_MARK: &str = "› ";
static WARNED_UNKNOWN_THEME: LazyLock<Mutex<HashSet<String>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
#[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,
pub canvas: Color,
pub code_text: Color,
pub code_comment: Color,
pub mono: bool,
}
impl Palette {
pub fn for_interface(interface: &InterfaceSettings) -> Self {
let palette = match interface.mode() {
ThemeMode::System => Self::for_theme(interface.theme),
ThemeMode::Full => Self::full(&interface.full_theme),
ThemeMode::Mono => Self::mono(),
};
palette.with_compat(interface.terminal_compat)
}
pub fn full(name: &str) -> Self {
if let Some(palette) = Self::built_in(name) {
return palette;
}
if let Some(theme) = crate::shared::user_theme::registry().get(name) {
return theme.palette;
}
let mut warned = WARNED_UNKNOWN_THEME
.lock()
.expect("unknown-theme set poisoned");
if warned.insert(name.to_string()) {
tracing::warn!(
theme = name,
fallback = FULL_THEMES[0],
"interface.full_theme names a theme that is neither built in nor in data/themes"
);
}
Self::dark().on_canvas(CANVAS_DARK)
}
pub fn built_in(name: &str) -> Option<Self> {
match name {
"dark" => Some(Self::dark().on_canvas(CANVAS_DARK)),
"light" => Some(Self::light().on_canvas(CANVAS_LIGHT)),
_ => None,
}
}
pub fn mono() -> Self {
Self {
mono: true,
..Self::dark()
}
}
fn on_canvas(mut self, canvas: Color) -> Self {
self.canvas = canvas;
self
}
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,
code_text: reference.code_text,
code_comment: reference.code_comment,
dark: !light,
compat: false,
canvas: Color::Reset,
mono: 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(133, 139, 146), border: Color::Rgb(54, 58, 66), border_focus: Color::Rgb(110, 117, 128), keycap_fg: Color::Rgb(133, 139, 147), keycap_bg: Color::Rgb(33, 36, 42), keycap_danger: Color::Rgb(232, 116, 104), code_text: Color::Rgb(212, 212, 212),
code_comment: Color::Rgb(138, 138, 138), dark: true,
compat: false,
canvas: Color::Reset,
mono: false,
}
}
fn light() -> Self {
Self {
user: Color::Rgb(0, 55, 218), assistant: Color::Rgb(0, 116, 0), tool: Color::Rgb(144, 85, 0), success: Color::Rgb(0, 116, 0),
warning: Color::Rgb(144, 85, 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(142, 87, 0), text: Color::Rgb(30, 32, 36),
muted: Color::Rgb(95, 100, 108), 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),
code_text: Color::Rgb(40, 40, 40),
code_comment: Color::Rgb(99, 99, 99), dark: false,
compat: false,
canvas: Color::Reset,
mono: false,
}
}
#[cfg(test)]
fn text_roles(&self) -> Vec<(&'static str, Color)> {
use crate::shared::user_theme::{Kind, ROLES};
ROLES
.iter()
.filter(|role| matches!(role.kind, Kind::Body | Kind::Text))
.map(|role| (role.name, (role.get)(self)))
.collect()
}
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 selected_mark(&self) -> Option<&'static str> {
self.mono.then_some(SELECTED_MARK)
}
pub fn tab_label(&self, label: &str, active: bool) -> String {
if self.mono && active {
format!("[{label}]")
} else {
format!(" {label} ")
}
}
pub fn selection(&self, style: Style) -> Style {
style.bg(if self.mono { MONO_MARK } else { self.keycap_bg })
}
pub fn search_match(&self, style: Style) -> Style {
if self.mono {
style.bg(MONO_MARK)
} else {
style.fg(self.accent)
}
}
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(
self.keycap_text(&label.into()),
Style::new().fg(self.keycap_fg).bg(self.keycap_bg),
)
}
fn keycap_text(&self, label: &str) -> String {
if self.mono {
format!("[{label}]")
} else {
format!(" {label} ")
}
}
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(
self.keycap_text(key),
Style::new().fg(self.keycap_danger).bg(self.keycap_bg),
),
Span::styled(format!(" {desc}"), self.muted_style()),
]
}
}
#[cfg(test)]
pub(crate) fn contrast_ratio(a: Color, b: Color) -> f32 {
use crate::shared::osc11::{Rgb, relative_luminance};
let luminance = |c: Color| match c {
Color::Rgb(r, g, b) => relative_luminance(Rgb { r, g, b }),
other => panic!("{other:?} is not an absolute colour"),
};
let (la, lb) = (luminance(a), luminance(b));
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
}
#[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)
);
}
fn interface(mode: ThemeMode, theme: Theme, full_theme: &str) -> InterfaceSettings {
let mut i = InterfaceSettings {
theme,
full_theme: full_theme.to_string(),
..Default::default()
};
i.set_mode(mode);
i
}
#[test]
fn the_system_mode_is_the_palette_it_always_was() {
for theme in [Theme::Auto, Theme::Dark, Theme::Light] {
let p = Palette::for_interface(&interface(ThemeMode::System, theme, "light"));
assert_eq!(p, Palette::for_theme(theme), "{theme:?}");
assert_eq!(p.canvas, Color::Reset, "{theme:?} paints a background");
}
assert_eq!(
Palette::for_interface(&InterfaceSettings::default()),
Palette::default()
);
}
#[test]
fn the_full_mode_is_a_built_in_palette_on_its_canvas() {
for (name, base, canvas) in [
("dark", Palette::dark(), CANVAS_DARK),
("light", Palette::light(), CANVAS_LIGHT),
] {
let p = Palette::for_interface(&interface(ThemeMode::Full, Theme::Auto, name));
assert_eq!(p.canvas, canvas, "{name}");
assert_eq!(
Palette {
canvas: Color::Reset,
..p
},
base,
"{name}: the full palette differs from the shared one in more than its canvas"
);
}
}
#[test]
fn every_listed_full_theme_is_its_own_palette() {
let palettes: Vec<Palette> = FULL_THEMES.iter().map(|n| Palette::full(n)).collect();
for (i, a) in palettes.iter().enumerate() {
for b in &palettes[i + 1..] {
assert_ne!(a, b, "two listed themes draw the same palette");
}
}
}
#[test]
fn a_full_theme_is_built_in_or_the_users() {
use crate::shared::user_theme::{Registry, parse, with_registry};
let registry = Registry::of([
parse("sepia", r##"{"canvas": "#f4ecd8"}"##).unwrap(),
parse("dark", r##"{"canvas": "#ffffff"}"##).unwrap(),
]);
let sepia = registry.get("sepia").unwrap().palette;
with_registry(registry, || {
assert_eq!(Palette::full("sepia"), sepia);
assert_eq!(Palette::full("sepia").canvas, Color::Rgb(0xf4, 0xec, 0xd8));
assert_eq!(Palette::full("dark").canvas, CANVAS_DARK);
assert_eq!(Palette::full("gruvbox"), Palette::full("dark"));
let p = Palette::for_interface(&interface(ThemeMode::Full, Theme::Dark, "sepia"));
assert_eq!(p, sepia);
let p = Palette::for_interface(&interface(ThemeMode::System, Theme::Dark, "sepia"));
assert_eq!(p, Palette::for_theme(Theme::Dark));
let p = Palette::for_interface(&interface(ThemeMode::Mono, Theme::Dark, "sepia"));
assert_eq!(p, Palette::mono());
});
assert_eq!(Palette::full("sepia"), Palette::full("dark"));
}
#[test]
fn the_built_in_themes_are_the_listed_ones() {
for name in FULL_THEMES {
assert_eq!(Palette::built_in(name), Some(Palette::full(name)), "{name}");
}
for name in ["", "Dark", "auto", "sepia", "mono"] {
assert_eq!(Palette::built_in(name), None, "{name:?}");
}
}
#[test]
fn an_unknown_full_theme_draws_as_the_first_built_in_one() {
assert_eq!(Palette::full("gruvbox"), Palette::full(FULL_THEMES[0]));
assert_eq!(Palette::full("gruvbox"), Palette::full("dark"));
assert!(
WARNED_UNKNOWN_THEME
.lock()
.unwrap()
.contains(&"gruvbox".to_string())
);
}
#[test]
fn the_monochrome_mode_is_one_palette_whatever_the_themes_say() {
let mono = Palette::mono();
assert!(mono.mono);
assert_eq!(mono.canvas, Color::Reset, "it paints no background");
for theme in [Theme::Auto, Theme::Dark, Theme::Light] {
for full in ["dark", "light", "gruvbox"] {
let p = Palette::for_interface(&interface(ThemeMode::Mono, theme, full));
assert_eq!(p, mono, "{theme:?} / {full}");
}
}
for mode in [ThemeMode::System, ThemeMode::Full] {
let p = Palette::for_interface(&interface(mode, Theme::Dark, "dark"));
assert!(!p.mono, "{mode:?}");
assert_ne!(p, mono, "{mode:?}");
}
}
#[test]
fn the_mark_is_a_colour_no_palette_has() {
let palettes = [
Palette::auto_with(None),
Palette::auto_with(Some(Background::Light)),
Palette::dark(),
Palette::light(),
Palette::mono(),
];
for p in palettes {
let all = p.text_roles().into_iter().chain([
("border", p.border),
("border_focus", p.border_focus),
("keycap_bg", p.keycap_bg),
("canvas", p.canvas),
]);
for (role, color) in all {
assert_ne!(color, MONO_MARK, "{role}");
}
}
for canvas in [CANVAS_DARK, CANVAS_LIGHT] {
assert_ne!(canvas, MONO_MARK);
}
}
#[test]
fn a_glyph_says_in_monochrome_what_styling_says_elsewhere() {
let (plain, mono) = (Palette::dark(), Palette::mono());
assert_eq!(plain.selected_mark(), None);
assert_eq!(mono.selected_mark(), Some(SELECTED_MARK));
assert_eq!(plain.tab_label("Keys", true), " Keys ");
assert_eq!(plain.tab_label("Keys", false), " Keys ");
assert_eq!(mono.tab_label("Keys", true), "[Keys]");
assert_eq!(mono.tab_label("Keys", false), " Keys ");
assert_eq!(plain.keycap("Enter").content, " Enter ");
assert_eq!(mono.keycap("Enter").content, "[Enter]");
assert_eq!(plain.hint_marked("Del", "delete", true)[0].content, " Del ");
assert_eq!(mono.hint_marked("Del", "delete", true)[0].content, "[Del]");
assert_eq!(mono.hint("F1", "help")[0].content, "[F1]");
}
#[test]
fn the_two_marks_are_the_only_things_drawn_on_the_mark() {
let (plain, mono) = (Palette::dark(), Palette::mono());
let under = Style::new().bold().fg(Color::Red);
assert_eq!(plain.search_match(under), under.fg(plain.accent));
assert_eq!(mono.search_match(under), under.bg(MONO_MARK));
assert_eq!(plain.selection(under), under.bg(plain.keycap_bg));
assert_eq!(mono.selection(under), under.bg(MONO_MARK));
let handed_out = [
mono.keycap("K").style,
mono.hint_marked("K", "d", true)[0].style,
mono.muted_style(),
mono.accent_style(),
mono.success_style(),
mono.border_style(true),
];
for style in handed_out {
assert_ne!(style.bg, Some(MONO_MARK));
}
}
#[test]
fn the_compatibility_flag_rides_on_either_mode() {
for mode in ThemeMode::ALL {
let mut i = interface(mode, Theme::Dark, "dark");
i.terminal_compat = true;
assert!(Palette::for_interface(&i).compat, "{mode:?}");
}
}
#[test]
fn a_full_palette_leaves_nothing_to_the_terminal() {
for name in FULL_THEMES {
let p = Palette::full(name);
let all = p
.text_roles()
.into_iter()
.chain([
("border", p.border),
("border_focus", p.border_focus),
("keycap_bg", p.keycap_bg),
("canvas", p.canvas),
])
.collect::<Vec<_>>();
for (role, color) in all {
assert!(
matches!(color, Color::Rgb(..)),
"{name}: {role} is {color:?}, not an absolute colour"
);
}
}
}
#[test]
fn a_canvas_is_on_the_side_its_palette_says() {
for name in FULL_THEMES {
let p = Palette::full(name);
let is_dark = contrast_ratio(p.canvas, Color::Rgb(255, 255, 255))
> contrast_ratio(p.canvas, Color::Rgb(0, 0, 0));
assert_eq!(p.dark, is_dark, "{name}");
}
}
#[test]
fn built_in_palettes_clear_the_contrast_floors() {
const BODY: f32 = 7.0; const TEXT: f32 = 4.5; const COMPONENT: f32 = 3.0;
let mut short: Vec<String> = Vec::new();
for name in FULL_THEMES {
let p = Palette::full(name);
for (role, color) in p.text_roles() {
let floor = if role == "text" { BODY } else { TEXT };
for (ground, on) in [("canvas", p.canvas), ("selection", p.keycap_bg)] {
let got = contrast_ratio(color, on);
if got < floor {
short.push(format!(
"{name}: {role} on {ground} is {got:.2}, floor {floor}"
));
}
}
}
let focus = contrast_ratio(p.border_focus, p.canvas);
if focus < COMPONENT {
short.push(format!(
"{name}: border_focus is {focus:.2}, floor {COMPONENT}"
));
}
}
assert!(short.is_empty(), "below the floor:\n{}", short.join("\n"));
}
#[test]
fn the_contrast_ratio_is_the_wcag_one() {
let (black, white) = (Color::Rgb(0, 0, 0), Color::Rgb(255, 255, 255));
assert!((contrast_ratio(black, white) - 21.0).abs() < 0.01);
assert!((contrast_ratio(white, black) - 21.0).abs() < 0.01, "order");
assert!((contrast_ratio(white, white) - 1.0).abs() < 0.001);
let got = contrast_ratio(Color::Rgb(201, 204, 210), CANVAS_DARK);
assert!((got - 11.74).abs() < 0.01, "{got}");
}
#[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);
}
}