1use std::fs;
16use std::path::{Path, PathBuf};
17
18use ftui::render::cell::PackedRgba;
19use ftui::style::theme::themes;
20use ftui::{
21 AdaptiveColor, Color, ColorProfile, ResolvedTheme, Style, StyleSheet, TableTheme,
22 TerminalCapabilities, Theme, ThemeBuilder,
23};
24use ftui_extras::markdown::MarkdownTheme;
25use ftui_extras::syntax::HighlightTheme;
26use serde::{Deserialize, Serialize};
27
28pub const STYLE_APP_ROOT: &str = "app.root";
29pub const STYLE_PANE_BASE: &str = "pane.base";
30pub const STYLE_PANE_FOCUSED: &str = "pane.focused";
31pub const STYLE_PANE_TITLE_FOCUSED: &str = "pane.title.focused";
32pub const STYLE_PANE_TITLE_UNFOCUSED: &str = "pane.title.unfocused";
33pub const STYLE_SPLIT_HANDLE: &str = "split.handle";
34pub const STYLE_TEXT_PRIMARY: &str = "text.primary";
35pub const STYLE_TEXT_MUTED: &str = "text.muted";
36pub const STYLE_TEXT_SUBTLE: &str = "text.subtle";
37pub const STYLE_STATUS_SUCCESS: &str = "status.success";
38pub const STYLE_STATUS_WARNING: &str = "status.warning";
39pub const STYLE_STATUS_ERROR: &str = "status.error";
40pub const STYLE_STATUS_INFO: &str = "status.info";
41pub const STYLE_RESULT_ROW: &str = "result.row";
42pub const STYLE_RESULT_ROW_ALT: &str = "result.row.alt";
43pub const STYLE_RESULT_ROW_SELECTED: &str = "result.row.selected";
44pub const STYLE_ROLE_USER: &str = "role.user";
45pub const STYLE_ROLE_ASSISTANT: &str = "role.assistant";
46pub const STYLE_ROLE_TOOL: &str = "role.tool";
47pub const STYLE_ROLE_SYSTEM: &str = "role.system";
48pub const STYLE_ROLE_GUTTER_USER: &str = "role.gutter.user";
49pub const STYLE_ROLE_GUTTER_ASSISTANT: &str = "role.gutter.assistant";
50pub const STYLE_ROLE_GUTTER_TOOL: &str = "role.gutter.tool";
51pub const STYLE_ROLE_GUTTER_SYSTEM: &str = "role.gutter.system";
52pub const STYLE_SCORE_HIGH: &str = "score.high";
53pub const STYLE_SCORE_MID: &str = "score.mid";
54pub const STYLE_SCORE_LOW: &str = "score.low";
55pub const STYLE_SOURCE_LOCAL: &str = "source.local";
56pub const STYLE_SOURCE_REMOTE: &str = "source.remote";
57pub const STYLE_LOCATION: &str = "location";
58pub const STYLE_PILL_ACTIVE: &str = "pill.active";
59pub const STYLE_PILL_INACTIVE: &str = "pill.inactive";
60pub const STYLE_PILL_LABEL: &str = "pill.label";
61pub const STYLE_CRUMB_ACTIVE: &str = "crumb.active";
62pub const STYLE_CRUMB_INACTIVE: &str = "crumb.inactive";
63pub const STYLE_CRUMB_SEPARATOR: &str = "crumb.separator";
64pub const STYLE_TAB_ACTIVE: &str = "tab.active";
65pub const STYLE_TAB_INACTIVE: &str = "tab.inactive";
66pub const STYLE_DETAIL_FIND_CONTAINER: &str = "detail.find.container";
67pub const STYLE_DETAIL_FIND_QUERY: &str = "detail.find.query";
68pub const STYLE_DETAIL_FIND_MATCH_ACTIVE: &str = "detail.find.match.active";
69pub const STYLE_DETAIL_FIND_MATCH_INACTIVE: &str = "detail.find.match.inactive";
70pub const STYLE_QUERY_HIGHLIGHT: &str = "query.highlight";
71pub const STYLE_SEARCH_FOCUS: &str = "search.focus";
72pub const STYLE_MODAL_BACKDROP: &str = "modal.backdrop";
73pub const STYLE_KBD_KEY: &str = "kbd.key";
74pub const STYLE_KBD_DESC: &str = "kbd.desc";
75pub const THEME_CONFIG_VERSION: u32 = 1;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum UiThemePreset {
80 #[default]
81 #[serde(alias = "dark")]
82 TokyoNight,
83 #[serde(alias = "light")]
84 Daylight,
85 #[serde(alias = "high_contrast", alias = "highcontrast", alias = "hc")]
86 HighContrast,
87 #[serde(alias = "cat")]
88 Catppuccin,
89 Dracula,
90 Nord,
91 SolarizedDark,
92 SolarizedLight,
93 Monokai,
94 GruvboxDark,
95 OneDark,
96 RosePine,
97 Everforest,
98 Kanagawa,
99 AyuMirage,
100 Nightfox,
101 CyberpunkAurora,
102 Synthwave84,
103 #[serde(alias = "cb", alias = "cvd")]
104 Colorblind,
105}
106
107impl UiThemePreset {
108 pub const fn all() -> [Self; 19] {
109 [
110 Self::TokyoNight,
111 Self::Daylight,
112 Self::Catppuccin,
113 Self::Dracula,
114 Self::Nord,
115 Self::SolarizedDark,
116 Self::SolarizedLight,
117 Self::Monokai,
118 Self::GruvboxDark,
119 Self::OneDark,
120 Self::RosePine,
121 Self::Everforest,
122 Self::Kanagawa,
123 Self::AyuMirage,
124 Self::Nightfox,
125 Self::CyberpunkAurora,
126 Self::Synthwave84,
127 Self::HighContrast,
128 Self::Colorblind,
129 ]
130 }
131
132 pub const fn name(self) -> &'static str {
133 match self {
134 Self::TokyoNight => "Tokyo Night",
135 Self::Daylight => "Daylight",
136 Self::HighContrast => "High Contrast",
137 Self::Catppuccin => "Catppuccin Mocha",
138 Self::Dracula => "Dracula",
139 Self::Nord => "Nord",
140 Self::SolarizedDark => "Solarized Dark",
141 Self::SolarizedLight => "Solarized Light",
142 Self::Monokai => "Monokai",
143 Self::GruvboxDark => "Gruvbox Dark",
144 Self::OneDark => "One Dark",
145 Self::RosePine => "Ros\u{e9} Pine",
146 Self::Everforest => "Everforest",
147 Self::Kanagawa => "Kanagawa",
148 Self::AyuMirage => "Ayu Mirage",
149 Self::Nightfox => "Nightfox",
150 Self::CyberpunkAurora => "Cyberpunk Aurora",
151 Self::Synthwave84 => "Synthwave '84",
152 Self::Colorblind => "Colorblind",
153 }
154 }
155
156 pub fn next(self) -> Self {
157 let all = Self::all();
158 let idx = all.iter().position(|preset| *preset == self).unwrap_or(0);
159 all[(idx + 1) % all.len()]
160 }
161
162 pub fn previous(self) -> Self {
163 let all = Self::all();
164 let idx = all.iter().position(|preset| *preset == self).unwrap_or(0);
165 all[(idx + all.len() - 1) % all.len()]
166 }
167
168 pub fn parse(value: &str) -> Option<Self> {
169 match value.trim().to_ascii_lowercase().as_str() {
170 "dark" | "tokyo-night" | "tokyo_night" | "tokyonight" => Some(Self::TokyoNight),
171 "light" | "daylight" => Some(Self::Daylight),
172 "high-contrast" | "high_contrast" | "highcontrast" | "hc" => Some(Self::HighContrast),
173 "catppuccin" | "cat" | "catppuccin-mocha" => Some(Self::Catppuccin),
174 "dracula" => Some(Self::Dracula),
175 "nord" => Some(Self::Nord),
176 "solarized-dark" | "solarized_dark" => Some(Self::SolarizedDark),
177 "solarized-light" | "solarized_light" => Some(Self::SolarizedLight),
178 "monokai" => Some(Self::Monokai),
179 "gruvbox-dark" | "gruvbox_dark" | "gruvbox" => Some(Self::GruvboxDark),
180 "one-dark" | "one_dark" | "onedark" => Some(Self::OneDark),
181 "rose-pine" | "rose_pine" | "rosepine" => Some(Self::RosePine),
182 "everforest" => Some(Self::Everforest),
183 "kanagawa" => Some(Self::Kanagawa),
184 "ayu-mirage" | "ayu_mirage" | "ayumirage" => Some(Self::AyuMirage),
185 "nightfox" => Some(Self::Nightfox),
186 "cyberpunk-aurora" | "cyberpunk_aurora" | "cyberpunk" => Some(Self::CyberpunkAurora),
187 "synthwave-84" | "synthwave_84" | "synthwave84" | "synthwave" => {
188 Some(Self::Synthwave84)
189 }
190 "colorblind" | "colour-blind" | "color-blind" | "cb" | "cvd" => Some(Self::Colorblind),
191 _ => None,
192 }
193 }
194
195 fn base_theme(self) -> Theme {
196 match self {
197 Self::TokyoNight => tokyo_night_theme(),
198 Self::Daylight => themes::light(),
199 Self::HighContrast => high_contrast_theme(),
200 Self::Catppuccin => catppuccin_theme(),
201 Self::Dracula => themes::dracula(),
202 Self::Nord => themes::nord(),
203 Self::SolarizedDark => themes::solarized_dark(),
204 Self::SolarizedLight => themes::solarized_light(),
205 Self::Monokai => themes::monokai(),
206 Self::GruvboxDark => gruvbox_dark_theme(),
207 Self::OneDark => one_dark_theme(),
208 Self::RosePine => rose_pine_theme(),
209 Self::Everforest => everforest_theme(),
210 Self::Kanagawa => kanagawa_theme(),
211 Self::AyuMirage => ayu_mirage_theme(),
212 Self::Nightfox => nightfox_theme(),
213 Self::CyberpunkAurora => cyberpunk_aurora_theme(),
214 Self::Synthwave84 => synthwave_84_theme(),
215 Self::Colorblind => colorblind_theme(),
216 }
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ThemeConfig {
222 #[serde(default = "default_theme_config_version")]
223 pub version: u32,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub base_preset: Option<UiThemePreset>,
226}
227
228impl ThemeConfig {
229 pub fn from_json_str(raw: &str) -> Result<Self, ThemeConfigError> {
230 let config: Self =
231 serde_json::from_str(raw).map_err(|source| ThemeConfigError::ParseJson { source })?;
232 config.validate()?;
233 Ok(config)
234 }
235
236 pub fn to_json_pretty(&self) -> Result<String, ThemeConfigError> {
237 self.validate()?;
238 serde_json::to_string_pretty(self)
239 .map_err(|source| ThemeConfigError::SerializeJson { source })
240 }
241
242 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ThemeConfigError> {
243 let path = path.as_ref();
244 let raw = fs::read_to_string(path).map_err(|source| ThemeConfigError::ReadConfig {
245 path: path.to_path_buf(),
246 source,
247 })?;
248 Self::from_json_str(&raw)
249 }
250
251 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ThemeConfigError> {
252 let path = path.as_ref();
253 if let Some(parent) = path.parent() {
254 fs::create_dir_all(parent).map_err(|source| ThemeConfigError::WriteConfig {
255 path: parent.to_path_buf(),
256 source,
257 })?;
258 }
259
260 let payload = self.to_json_pretty()?;
261 fs::write(path, payload).map_err(|source| ThemeConfigError::WriteConfig {
262 path: path.to_path_buf(),
263 source,
264 })
265 }
266
267 pub fn validate(&self) -> Result<(), ThemeConfigError> {
268 if self.version != THEME_CONFIG_VERSION {
269 return Err(ThemeConfigError::UnsupportedVersion {
270 found: self.version,
271 expected: THEME_CONFIG_VERSION,
272 });
273 }
274 Ok(())
275 }
276}
277
278impl Default for ThemeConfig {
279 fn default() -> Self {
280 Self {
281 version: THEME_CONFIG_VERSION,
282 base_preset: None,
283 }
284 }
285}
286
287#[derive(Debug, Clone, PartialEq)]
288pub struct ThemeContrastCheck {
289 pub pair: &'static str,
290 pub ratio: f64,
291 pub minimum: f64,
292 pub passes: bool,
293}
294
295#[derive(Debug, Clone, PartialEq)]
296pub struct ThemeContrastReport {
297 pub checks: Vec<ThemeContrastCheck>,
298}
299
300impl ThemeContrastReport {
301 pub fn has_failures(&self) -> bool {
302 self.checks.iter().any(|check| !check.passes)
303 }
304
305 pub fn failing_pairs(&self) -> Vec<&'static str> {
306 self.checks
307 .iter()
308 .filter(|check| !check.passes)
309 .map(|check| check.pair)
310 .collect()
311 }
312}
313
314#[derive(Debug, thiserror::Error)]
315pub enum ThemeConfigError {
316 #[error("unsupported theme config version {found}; expected {expected}")]
317 UnsupportedVersion { found: u32, expected: u32 },
318 #[error("failed to parse theme config JSON: {source}")]
319 ParseJson { source: serde_json::Error },
320 #[error("failed to serialize theme config JSON: {source}")]
321 SerializeJson { source: serde_json::Error },
322 #[error("failed to read theme config `{path}`: {source}")]
323 ReadConfig {
324 path: PathBuf,
325 source: std::io::Error,
326 },
327 #[error("failed to write theme config `{path}`: {source}")]
328 WriteConfig {
329 path: PathBuf,
330 source: std::io::Error,
331 },
332}
333
334fn default_theme_config_version() -> u32 {
335 THEME_CONFIG_VERSION
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct StyleOptions {
340 pub preset: UiThemePreset,
341 pub dark_mode: bool,
342 pub color_profile: ColorProfile,
343 pub no_color: bool,
344 pub no_icons: bool,
345 pub no_gradient: bool,
346 pub a11y: bool,
347}
348
349impl Default for StyleOptions {
350 fn default() -> Self {
351 Self {
352 preset: UiThemePreset::TokyoNight,
353 dark_mode: true,
354 color_profile: ColorProfile::detect(),
355 no_color: false,
356 no_icons: false,
357 no_gradient: false,
358 a11y: false,
359 }
360 }
361}
362
363#[derive(Debug, Clone, Copy, Default)]
364struct EnvValues<'a> {
365 no_color: Option<&'a str>,
366 cass_respect_no_color: Option<&'a str>,
367 cass_no_color: Option<&'a str>,
368 colorterm: Option<&'a str>,
369 term: Option<&'a str>,
370 cass_no_icons: Option<&'a str>,
371 cass_no_gradient: Option<&'a str>,
372 cass_a11y: Option<&'a str>,
373 cass_theme: Option<&'a str>,
374 cass_color_profile: Option<&'a str>,
375}
376
377impl StyleOptions {
378 pub fn from_env() -> Self {
379 let no_color = dotenvy::var("NO_COLOR").ok();
380 let cass_respect_no_color = dotenvy::var("CASS_RESPECT_NO_COLOR").ok();
381 let cass_no_color = dotenvy::var("CASS_NO_COLOR").ok();
382 let colorterm = dotenvy::var("COLORTERM").ok();
383 let term = dotenvy::var("TERM").ok();
384 let cass_no_icons = dotenvy::var("CASS_NO_ICONS").ok();
385 let cass_no_gradient = dotenvy::var("CASS_NO_GRADIENT").ok();
386 let cass_a11y = dotenvy::var("CASS_A11Y").ok();
387 let cass_theme = dotenvy::var("CASS_THEME").ok();
388 let cass_color_profile = dotenvy::var("CASS_COLOR_PROFILE").ok();
389
390 let mut options = Self::from_env_values(EnvValues {
391 no_color: no_color.as_deref(),
392 cass_respect_no_color: cass_respect_no_color.as_deref(),
393 cass_no_color: cass_no_color.as_deref(),
394 colorterm: colorterm.as_deref(),
395 term: term.as_deref(),
396 cass_no_icons: cass_no_icons.as_deref(),
397 cass_no_gradient: cass_no_gradient.as_deref(),
398 cass_a11y: cass_a11y.as_deref(),
399 cass_theme: cass_theme.as_deref(),
400 cass_color_profile: cass_color_profile.as_deref(),
401 });
402
403 if !options.no_color && cass_color_profile.is_none() {
407 let caps = TerminalCapabilities::with_overrides();
408 options.color_profile = if caps.true_color {
409 ColorProfile::TrueColor
410 } else if caps.colors_256 {
411 ColorProfile::Ansi256
412 } else {
413 ColorProfile::Ansi16
414 };
415 }
416
417 options
418 }
419
420 fn from_env_values(values: EnvValues<'_>) -> Self {
445 let preset = values
446 .cass_theme
447 .and_then(UiThemePreset::parse)
448 .unwrap_or(UiThemePreset::TokyoNight);
449
450 let no_color_enabled = env_truthy(values.cass_no_color)
451 || (env_truthy(values.cass_respect_no_color) && values.no_color.is_some());
452
453 let detected_profile = ColorProfile::detect_from_env(None, values.colorterm, values.term);
454 let profile_override = values.cass_color_profile.and_then(parse_color_profile);
455 let color_profile = if no_color_enabled {
456 ColorProfile::Mono
457 } else {
458 profile_override.unwrap_or(detected_profile)
459 };
460
461 let a11y = env_truthy(values.cass_a11y);
462 let no_icons = env_truthy(values.cass_no_icons);
463 let no_gradient = env_truthy(values.cass_no_gradient) || no_color_enabled || a11y;
464
465 let dark_mode = match preset {
466 UiThemePreset::Daylight | UiThemePreset::SolarizedLight => false,
467 UiThemePreset::HighContrast => Theme::detect_dark_mode(),
468 _ => true,
469 };
470
471 Self {
472 preset,
473 dark_mode,
474 color_profile,
475 no_color: no_color_enabled,
476 no_icons,
477 no_gradient,
478 a11y,
479 }
480 }
481
482 pub const fn gradients_enabled(self) -> bool {
483 !self.no_gradient && self.color_profile.supports_color()
484 }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
493pub enum BorderTier {
494 Rounded,
496 Square,
498 None,
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub struct DecorativePolicy {
520 pub border_tier: BorderTier,
522 pub show_icons: bool,
524 pub use_styling: bool,
526 pub use_gradients: bool,
528 pub render_content: bool,
530}
531
532impl DecorativePolicy {
533 pub fn resolve(
537 options: StyleOptions,
538 degradation: ftui::render::budget::DegradationLevel,
539 breakpoint: super::app::LayoutBreakpoint,
540 fancy_borders: bool,
541 ) -> Self {
542 use crate::ui::app::LayoutBreakpoint as LB;
543
544 let render_content = degradation.render_content();
545
546 let border_tier = if !degradation.render_decorative() {
548 BorderTier::None
549 } else if !degradation.use_unicode_borders() {
550 BorderTier::Square
552 } else if !fancy_borders {
553 BorderTier::Square
554 } else if breakpoint == LB::Narrow {
555 BorderTier::Square
557 } else {
558 BorderTier::Rounded
559 };
560
561 let show_icons = degradation.render_decorative() && !options.no_icons;
562 let use_styling = degradation.apply_styling() && !options.no_color;
563 let use_gradients = options.gradients_enabled() && degradation.apply_styling();
564
565 Self {
566 border_tier,
567 show_icons,
568 use_styling,
569 use_gradients,
570 render_content,
571 }
572 }
573}
574
575#[derive(Debug, Clone, Copy, Default)]
581pub struct CapabilityMatrixInputs<'a> {
582 pub term: Option<&'a str>,
584 pub colorterm: Option<&'a str>,
586 pub no_color: bool,
588 pub cass_respect_no_color: bool,
590 pub cass_no_color: bool,
592 pub cass_no_icons: bool,
594 pub cass_no_gradient: bool,
596 pub cass_a11y: bool,
598 pub cass_theme: Option<&'a str>,
600 pub cass_color_profile: Option<&'a str>,
602}
603
604#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
606pub struct StylePolicyDiagnostic {
607 pub terminal_profile: String,
609 pub term: Option<String>,
611 pub colorterm: Option<String>,
613 pub degradation: &'static str,
615 pub breakpoint: &'static str,
617 pub fancy_borders: bool,
619 pub capability_true_color: bool,
621 pub capability_colors_256: bool,
623 pub capability_unicode_box_drawing: bool,
625 pub env_no_color: bool,
627 pub env_cass_respect_no_color: bool,
629 pub env_cass_no_color: bool,
631 pub resolved_color_profile: &'static str,
633 pub resolved_no_color: bool,
635 pub resolved_no_icons: bool,
637 pub resolved_no_gradient: bool,
639 pub policy_border_tier: &'static str,
641 pub policy_show_icons: bool,
643 pub policy_use_styling: bool,
645 pub policy_use_gradients: bool,
647 pub policy_render_content: bool,
649}
650
651fn env_flag(value: bool) -> Option<&'static str> {
652 if value { Some("1") } else { None }
653}
654
655fn color_profile_name(profile: ColorProfile) -> &'static str {
656 match profile {
657 ColorProfile::Mono => "mono",
658 ColorProfile::Ansi16 => "ansi16",
659 ColorProfile::Ansi256 => "ansi256",
660 ColorProfile::TrueColor => "truecolor",
661 }
662}
663
664fn border_tier_name(tier: BorderTier) -> &'static str {
665 match tier {
666 BorderTier::Rounded => "rounded",
667 BorderTier::Square => "square",
668 BorderTier::None => "none",
669 }
670}
671
672fn breakpoint_name(breakpoint: super::app::LayoutBreakpoint) -> &'static str {
673 use crate::ui::app::LayoutBreakpoint as LB;
674 match breakpoint {
675 LB::Narrow => "narrow",
676 LB::MediumNarrow => "medium-narrow",
677 LB::Medium => "medium",
678 LB::Wide => "wide",
679 LB::UltraWide => "ultra-wide",
680 }
681}
682
683fn degradation_name(level: ftui::render::budget::DegradationLevel) -> &'static str {
684 use ftui::render::budget::DegradationLevel as DL;
685 match level {
686 DL::Full => "full",
687 DL::SimpleBorders => "simple-borders",
688 DL::NoStyling => "no-styling",
689 DL::EssentialOnly => "essential-only",
690 DL::Skeleton => "skeleton",
691 DL::SkipFrame => "skip-frame",
692 }
693}
694
695pub fn style_policy_diagnostic(
701 capabilities: TerminalCapabilities,
702 inputs: CapabilityMatrixInputs<'_>,
703 degradation: ftui::render::budget::DegradationLevel,
704 breakpoint: super::app::LayoutBreakpoint,
705 fancy_borders: bool,
706) -> StylePolicyDiagnostic {
707 let env_values = EnvValues {
708 no_color: env_flag(inputs.no_color),
709 cass_respect_no_color: env_flag(inputs.cass_respect_no_color),
710 cass_no_color: env_flag(inputs.cass_no_color),
711 colorterm: inputs.colorterm,
712 term: inputs.term,
713 cass_no_icons: env_flag(inputs.cass_no_icons),
714 cass_no_gradient: env_flag(inputs.cass_no_gradient),
715 cass_a11y: env_flag(inputs.cass_a11y),
716 cass_theme: inputs.cass_theme,
717 cass_color_profile: inputs.cass_color_profile,
718 };
719
720 let mut options = StyleOptions::from_env_values(env_values);
721
722 if !options.no_color && inputs.cass_color_profile.is_none() {
725 options.color_profile = if capabilities.true_color {
726 ColorProfile::TrueColor
727 } else if capabilities.colors_256 {
728 ColorProfile::Ansi256
729 } else {
730 ColorProfile::Ansi16
731 };
732 }
733
734 let policy = DecorativePolicy::resolve(options, degradation, breakpoint, fancy_borders);
735
736 StylePolicyDiagnostic {
737 terminal_profile: capabilities.profile().as_str().to_string(),
738 term: inputs.term.map(ToString::to_string),
739 colorterm: inputs.colorterm.map(ToString::to_string),
740 degradation: degradation_name(degradation),
741 breakpoint: breakpoint_name(breakpoint),
742 fancy_borders,
743 capability_true_color: capabilities.true_color,
744 capability_colors_256: capabilities.colors_256,
745 capability_unicode_box_drawing: capabilities.unicode_box_drawing,
746 env_no_color: inputs.no_color,
747 env_cass_respect_no_color: inputs.cass_respect_no_color,
748 env_cass_no_color: inputs.cass_no_color,
749 resolved_color_profile: color_profile_name(options.color_profile),
750 resolved_no_color: options.no_color,
751 resolved_no_icons: options.no_icons,
752 resolved_no_gradient: options.no_gradient,
753 policy_border_tier: border_tier_name(policy.border_tier),
754 policy_show_icons: policy.show_icons,
755 policy_use_styling: policy.use_styling,
756 policy_use_gradients: policy.use_gradients,
757 policy_render_content: policy.render_content,
758 }
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub struct RoleMarkers {
763 pub user: &'static str,
764 pub assistant: &'static str,
765 pub tool: &'static str,
766 pub system: &'static str,
767}
768
769impl RoleMarkers {
770 fn from_options(options: StyleOptions) -> Self {
771 if options.a11y {
772 return Self {
773 user: "[user]",
774 assistant: "[assistant]",
775 tool: "[tool]",
776 system: "[system]",
777 };
778 }
779
780 if options.no_icons {
781 return Self {
782 user: "",
783 assistant: "",
784 tool: "",
785 system: "",
786 };
787 }
788
789 Self {
790 user: "U>",
791 assistant: "A>",
792 tool: "T>",
793 system: "S>",
794 }
795 }
796}
797
798#[derive(Debug, Clone)]
799pub struct StyleContext {
800 pub options: StyleOptions,
801 pub theme: Theme,
802 pub resolved: ResolvedTheme,
803 pub sheet: StyleSheet,
804 pub role_markers: RoleMarkers,
805}
806
807impl StyleContext {
808 pub fn from_options(options: StyleOptions) -> Self {
809 Self::build(options)
810 }
811
812 pub fn from_options_with_theme_config(mut options: StyleOptions, config: &ThemeConfig) -> Self {
813 if let Some(base_preset) = config.base_preset {
814 options.preset = base_preset;
815 options.dark_mode = match base_preset {
816 UiThemePreset::Daylight | UiThemePreset::SolarizedLight => false,
817 UiThemePreset::HighContrast => Theme::detect_dark_mode(),
818 _ => true,
819 };
820 }
821
822 Self::build(options)
823 }
824
825 fn build(options: StyleOptions) -> Self {
826 let mut theme = options.preset.base_theme();
827
828 if options.a11y && options.preset != UiThemePreset::HighContrast {
829 theme = apply_a11y_overrides(theme);
830 }
831
832 theme = downgrade_theme_for_profile(theme, options.color_profile);
833
834 let dark_mode = match options.preset {
835 UiThemePreset::Daylight | UiThemePreset::SolarizedLight => false,
836 _ => options.dark_mode,
837 };
838 let resolved = theme.resolve(dark_mode);
839 let sheet = build_stylesheet(resolved, options);
840 let role_markers = RoleMarkers::from_options(options);
841
842 Self {
843 options,
844 theme,
845 resolved,
846 sheet,
847 role_markers,
848 }
849 }
850
851 pub fn from_env() -> Self {
852 Self::from_options(StyleOptions::from_env())
853 }
854
855 pub fn style(&self, name: &str) -> Style {
856 self.sheet.get_or_default(name)
857 }
858
859 pub fn agent_accent_style(&self, agent: &str) -> Style {
861 let pane = super::components::theme::ThemePalette::agent_pane(agent);
862 if self.options.no_color
863 || self.options.a11y
864 || !self.options.color_profile.supports_color()
865 {
866 return Style::new().fg(pane.accent).bold();
867 }
868
869 let accent = Color::rgb(pane.accent.r(), pane.accent.g(), pane.accent.b());
870 let badge_bg = blend(
871 self.resolved.surface,
872 accent,
873 if self.options.gradients_enabled() {
874 0.22
875 } else {
876 0.14
877 },
878 );
879
880 let mut best_fg = self.resolved.text;
882 let mut best_ratio =
883 ftui::style::contrast_ratio_packed(to_packed(best_fg), to_packed(badge_bg));
884 for candidate in [self.resolved.selection_fg, accent] {
885 let ratio =
886 ftui::style::contrast_ratio_packed(to_packed(candidate), to_packed(badge_bg));
887 if ratio > best_ratio {
888 best_ratio = ratio;
889 best_fg = candidate;
890 }
891 }
892
893 Style::new()
894 .fg(to_packed(best_fg))
895 .bg(to_packed(badge_bg))
896 .bold()
897 }
898
899 pub fn result_row_style_for_agent(&self, base: Style, agent: &str) -> Style {
904 let Some(base_bg) = base.bg else {
905 return base;
906 };
907 if self.options.no_color
908 || self.options.a11y
909 || !self.options.color_profile.supports_color()
910 {
911 return base;
912 }
913
914 let pane = super::components::theme::ThemePalette::agent_pane(agent);
915 let accent = Color::rgb(pane.accent.r(), pane.accent.g(), pane.accent.b());
916 let pane_bg = Color::rgb(pane.bg.r(), pane.bg.g(), pane.bg.b());
917 let base_bg_color = Color::rgb(base_bg.r(), base_bg.g(), base_bg.b());
918 let mut tint_mix = if self.options.gradients_enabled() {
919 0.12
920 } else {
921 0.08
922 };
923 let selected_bg = self.resolved.selection_bg;
924 let base_separation =
925 ftui::style::contrast_ratio_packed(to_packed(selected_bg), to_packed(base_bg_color));
926 let min_allowed_separation = (base_separation - 0.03).max(1.01);
927 let mut tint = blend(base_bg_color, pane_bg, tint_mix);
928 let mut tint_separation =
929 ftui::style::contrast_ratio_packed(to_packed(selected_bg), to_packed(tint));
930
931 if tint_separation < min_allowed_separation {
934 for _ in 0..4 {
935 tint_mix *= 0.55;
936 let candidate = blend(base_bg_color, pane_bg, tint_mix);
937 let candidate_separation = ftui::style::contrast_ratio_packed(
938 to_packed(selected_bg),
939 to_packed(candidate),
940 );
941 tint = candidate;
942 tint_separation = candidate_separation;
943 if candidate_separation >= min_allowed_separation {
944 break;
945 }
946 }
947 }
948
949 let agent_hash = agent.as_bytes().iter().fold(0u32, |acc, b| {
952 acc.wrapping_mul(131).wrapping_add(u32::from(*b))
953 });
954 let signature_mix_base = if self.options.gradients_enabled() {
955 0.010
956 } else {
957 0.006
958 };
959 let signature_mix = signature_mix_base + (agent_hash % 5) as f32 * 0.0015;
960 let signature_tint = blend(tint, accent, signature_mix);
961 let signature_separation =
962 ftui::style::contrast_ratio_packed(to_packed(selected_bg), to_packed(signature_tint));
963 if signature_separation >= min_allowed_separation {
964 tint = signature_tint;
965 tint_separation = signature_separation;
966 }
967
968 debug_assert!(
969 tint_separation > 0.0,
970 "contrast ratios should always be positive"
971 );
972 Style::new()
973 .fg(base.fg.unwrap_or_else(|| to_packed(self.resolved.text)))
974 .bg(to_packed(tint))
975 }
976
977 pub fn score_style(&self, score: f32) -> Style {
979 if score >= 8.0 {
980 self.style(STYLE_SCORE_HIGH)
981 } else if score >= 5.0 {
982 self.style(STYLE_SCORE_MID)
983 } else {
984 self.style(STYLE_SCORE_LOW)
985 }
986 }
987
988 pub fn contrast_report(&self) -> ThemeContrastReport {
989 build_contrast_report(self.resolved)
990 }
991
992 pub fn markdown_theme(&self) -> MarkdownTheme {
995 let r = &self.resolved;
996 MarkdownTheme {
997 h1: Style::new().fg(to_packed(r.primary)).bold(),
998 h2: Style::new().fg(to_packed(r.info)).bold(),
999 h3: Style::new().fg(to_packed(r.success)).bold(),
1000 h4: Style::new().fg(to_packed(r.warning)).bold(),
1001 h5: Style::new().fg(to_packed(r.text)).bold(),
1002 h6: Style::new().fg(to_packed(r.text_muted)).bold(),
1003 code_inline: Style::new()
1004 .fg(to_packed(r.text))
1005 .bg(to_packed(blend(r.surface, r.text, 0.08))),
1006 code_block: Style::new().fg(to_packed(r.text)).bg(to_packed(blend(
1007 r.background,
1008 r.surface,
1009 0.5,
1010 ))),
1011 blockquote: Style::new().fg(to_packed(r.text_muted)).italic(),
1012 link: Style::new().fg(to_packed(r.info)).underline(),
1013 emphasis: Style::new().fg(to_packed(r.text)).italic(),
1014 strong: Style::new().fg(to_packed(r.text)).bold(),
1015 strikethrough: Style::new().fg(to_packed(r.text_muted)).strikethrough(),
1016 list_bullet: Style::new().fg(to_packed(r.info)),
1017 horizontal_rule: Style::new().fg(to_packed(r.border)).dim(),
1018 table_theme: TableTheme {
1019 border: Style::new().fg(to_packed(r.border)),
1020 header: Style::new()
1021 .fg(to_packed(r.text))
1022 .bg(to_packed(blend(r.surface, r.primary, 0.15)))
1023 .bold(),
1024 row: Style::new().fg(to_packed(r.text)),
1025 row_alt: Style::new().fg(to_packed(r.text)).bg(to_packed(blend(
1026 r.background,
1027 r.surface,
1028 0.3,
1029 ))),
1030 divider: Style::new().fg(to_packed(r.border)).dim(),
1031 ..TableTheme::default()
1032 },
1033 task_done: Style::new().fg(to_packed(r.success)),
1034 task_todo: Style::new().fg(to_packed(r.text_muted)),
1035 math_inline: Style::new().fg(to_packed(r.warning)).italic(),
1036 math_block: Style::new().fg(to_packed(r.warning)).bold(),
1037 footnote_ref: Style::new().fg(to_packed(r.info)).dim(),
1038 footnote_def: Style::new().fg(to_packed(r.text_muted)),
1039 admonition_note: Style::new().fg(to_packed(r.info)).bold(),
1040 admonition_tip: Style::new().fg(to_packed(r.success)).bold(),
1041 admonition_important: Style::new().fg(to_packed(r.primary)).bold(),
1042 admonition_warning: Style::new().fg(to_packed(r.warning)).bold(),
1043 admonition_caution: Style::new().fg(to_packed(r.error)).bold(),
1044 }
1045 }
1046
1047 pub fn syntax_highlight_theme(&self) -> HighlightTheme {
1049 if self.options.dark_mode {
1050 HighlightTheme::dark()
1051 } else {
1052 HighlightTheme::light()
1053 }
1054 }
1055}
1056
1057fn parse_color_profile(value: &str) -> Option<ColorProfile> {
1058 match value.trim().to_ascii_lowercase().as_str() {
1059 "mono" | "none" => Some(ColorProfile::Mono),
1060 "ansi16" | "16" => Some(ColorProfile::Ansi16),
1061 "ansi256" | "256" => Some(ColorProfile::Ansi256),
1062 "truecolor" | "24bit" | "rgb" => Some(ColorProfile::TrueColor),
1063 _ => None,
1064 }
1065}
1066
1067fn env_truthy(value: Option<&str>) -> bool {
1068 match value {
1069 Some(raw) => {
1070 let normalized = raw.trim().to_ascii_lowercase();
1071 if normalized.is_empty() {
1072 return false;
1073 }
1074 !(normalized == "0"
1075 || normalized == "false"
1076 || normalized == "off"
1077 || normalized == "no")
1078 }
1079 None => false,
1080 }
1081}
1082
1083fn tokyo_night_theme() -> Theme {
1084 ThemeBuilder::from_theme(themes::dark())
1085 .primary(Color::rgb(122, 162, 247))
1086 .secondary(Color::rgb(187, 154, 247))
1087 .accent(Color::rgb(125, 207, 255))
1088 .background(Color::rgb(26, 27, 38))
1089 .surface(Color::rgb(36, 40, 59))
1090 .overlay(Color::rgb(41, 46, 66))
1091 .text(Color::rgb(192, 202, 245))
1092 .text_muted(Color::rgb(169, 177, 214))
1093 .text_subtle(Color::rgb(105, 114, 158))
1094 .success(Color::rgb(115, 218, 202))
1095 .warning(Color::rgb(224, 175, 104))
1096 .error(Color::rgb(247, 118, 142))
1097 .info(Color::rgb(125, 207, 255))
1098 .border(Color::rgb(59, 66, 97))
1099 .border_focused(Color::rgb(125, 145, 200))
1100 .selection_bg(Color::rgb(122, 162, 247))
1101 .selection_fg(Color::rgb(26, 27, 38))
1102 .scrollbar_track(Color::rgb(41, 46, 66))
1103 .scrollbar_thumb(Color::rgb(125, 145, 200))
1104 .build()
1105}
1106
1107fn catppuccin_theme() -> Theme {
1108 ThemeBuilder::from_theme(themes::dark())
1109 .primary(Color::rgb(137, 180, 250))
1110 .secondary(Color::rgb(245, 194, 231))
1111 .accent(Color::rgb(203, 166, 247))
1112 .background(Color::rgb(30, 30, 46))
1113 .surface(Color::rgb(49, 50, 68))
1114 .overlay(Color::rgb(69, 71, 90))
1115 .text(Color::rgb(205, 214, 244))
1116 .text_muted(Color::rgb(166, 173, 200))
1117 .text_subtle(Color::rgb(127, 132, 156))
1118 .success(Color::rgb(166, 227, 161))
1119 .warning(Color::rgb(249, 226, 175))
1120 .error(Color::rgb(243, 139, 168))
1121 .info(Color::rgb(137, 220, 235))
1122 .border(Color::rgb(88, 91, 112))
1123 .border_focused(Color::rgb(180, 190, 254))
1124 .selection_bg(Color::rgb(116, 199, 236))
1125 .selection_fg(Color::rgb(30, 30, 46))
1126 .scrollbar_track(Color::rgb(49, 50, 68))
1127 .scrollbar_thumb(Color::rgb(88, 91, 112))
1128 .build()
1129}
1130
1131fn high_contrast_theme() -> Theme {
1132 ThemeBuilder::from_theme(themes::dark())
1133 .primary(AdaptiveColor::adaptive(
1134 Color::rgb(0, 0, 0),
1135 Color::rgb(255, 255, 255),
1136 ))
1137 .secondary(AdaptiveColor::adaptive(
1138 Color::rgb(0, 0, 0),
1139 Color::rgb(255, 255, 255),
1140 ))
1141 .accent(AdaptiveColor::adaptive(
1142 Color::rgb(0, 0, 0),
1143 Color::rgb(255, 255, 0),
1144 ))
1145 .background(AdaptiveColor::adaptive(
1146 Color::rgb(255, 255, 255),
1147 Color::rgb(0, 0, 0),
1148 ))
1149 .surface(AdaptiveColor::adaptive(
1150 Color::rgb(245, 245, 245),
1151 Color::rgb(0, 0, 0),
1152 ))
1153 .overlay(AdaptiveColor::adaptive(
1154 Color::rgb(235, 235, 235),
1155 Color::rgb(0, 0, 0),
1156 ))
1157 .text(AdaptiveColor::adaptive(
1158 Color::rgb(0, 0, 0),
1159 Color::rgb(255, 255, 255),
1160 ))
1161 .text_muted(AdaptiveColor::adaptive(
1162 Color::rgb(30, 30, 30),
1163 Color::rgb(215, 215, 215),
1164 ))
1165 .text_subtle(AdaptiveColor::adaptive(
1166 Color::rgb(45, 45, 45),
1167 Color::rgb(200, 200, 200),
1168 ))
1169 .success(AdaptiveColor::adaptive(
1170 Color::rgb(0, 96, 0),
1171 Color::rgb(64, 255, 64),
1172 ))
1173 .warning(AdaptiveColor::adaptive(
1174 Color::rgb(110, 70, 0),
1175 Color::rgb(255, 220, 64),
1176 ))
1177 .error(AdaptiveColor::adaptive(
1178 Color::rgb(128, 0, 0),
1179 Color::rgb(255, 96, 96),
1180 ))
1181 .info(AdaptiveColor::adaptive(
1182 Color::rgb(0, 32, 128),
1183 Color::rgb(128, 200, 255),
1184 ))
1185 .border(AdaptiveColor::adaptive(
1186 Color::rgb(0, 0, 0),
1187 Color::rgb(255, 255, 255),
1188 ))
1189 .border_focused(AdaptiveColor::adaptive(
1190 Color::rgb(0, 0, 0),
1191 Color::rgb(255, 255, 0),
1192 ))
1193 .selection_bg(AdaptiveColor::adaptive(
1194 Color::rgb(0, 0, 0),
1195 Color::rgb(255, 255, 255),
1196 ))
1197 .selection_fg(AdaptiveColor::adaptive(
1198 Color::rgb(255, 255, 255),
1199 Color::rgb(0, 0, 0),
1200 ))
1201 .scrollbar_track(AdaptiveColor::adaptive(
1202 Color::rgb(235, 235, 235),
1203 Color::rgb(0, 0, 0),
1204 ))
1205 .scrollbar_thumb(AdaptiveColor::adaptive(
1206 Color::rgb(0, 0, 0),
1207 Color::rgb(255, 255, 255),
1208 ))
1209 .build()
1210}
1211
1212fn gruvbox_dark_theme() -> Theme {
1213 ThemeBuilder::from_theme(themes::dark())
1214 .primary(Color::rgb(215, 153, 33)) .secondary(Color::rgb(211, 134, 155)) .accent(Color::rgb(250, 189, 47)) .background(Color::rgb(40, 40, 40)) .surface(Color::rgb(50, 48, 47)) .overlay(Color::rgb(60, 56, 54)) .text(Color::rgb(235, 219, 178)) .text_muted(Color::rgb(189, 174, 147)) .text_subtle(Color::rgb(146, 131, 116)) .success(Color::rgb(152, 151, 26)) .warning(Color::rgb(215, 153, 33)) .error(Color::rgb(204, 36, 29)) .info(Color::rgb(69, 133, 136)) .border(Color::rgb(80, 73, 69)) .border_focused(Color::rgb(250, 189, 47)) .selection_bg(Color::rgb(215, 153, 33)) .selection_fg(Color::rgb(40, 40, 40)) .scrollbar_track(Color::rgb(60, 56, 54)) .scrollbar_thumb(Color::rgb(146, 131, 116)) .build()
1234}
1235
1236fn one_dark_theme() -> Theme {
1237 ThemeBuilder::from_theme(themes::dark())
1238 .primary(Color::rgb(97, 175, 239)) .secondary(Color::rgb(198, 120, 221)) .accent(Color::rgb(86, 182, 194)) .background(Color::rgb(40, 44, 52)) .surface(Color::rgb(49, 53, 63)) .overlay(Color::rgb(55, 59, 69)) .text(Color::rgb(171, 178, 191)) .text_muted(Color::rgb(139, 145, 157)) .text_subtle(Color::rgb(99, 109, 131)) .success(Color::rgb(152, 195, 121)) .warning(Color::rgb(229, 192, 123)) .error(Color::rgb(224, 108, 117)) .info(Color::rgb(86, 182, 194)) .border(Color::rgb(62, 68, 81)) .border_focused(Color::rgb(97, 175, 239)) .selection_bg(Color::rgb(97, 175, 239)) .selection_fg(Color::rgb(40, 44, 52)) .scrollbar_track(Color::rgb(49, 53, 63)) .scrollbar_thumb(Color::rgb(99, 109, 131)) .build()
1258}
1259
1260fn rose_pine_theme() -> Theme {
1261 ThemeBuilder::from_theme(themes::dark())
1262 .primary(Color::rgb(235, 188, 186)) .secondary(Color::rgb(196, 167, 231)) .accent(Color::rgb(49, 116, 143)) .background(Color::rgb(25, 23, 36)) .surface(Color::rgb(38, 35, 53)) .overlay(Color::rgb(57, 53, 82)) .text(Color::rgb(224, 222, 244)) .text_muted(Color::rgb(144, 140, 170)) .text_subtle(Color::rgb(110, 106, 134)) .success(Color::rgb(156, 207, 216)) .warning(Color::rgb(246, 193, 119)) .error(Color::rgb(235, 111, 146)) .info(Color::rgb(49, 116, 143)) .border(Color::rgb(57, 53, 82)) .border_focused(Color::rgb(235, 188, 186)) .selection_bg(Color::rgb(235, 188, 186)) .selection_fg(Color::rgb(25, 23, 36)) .scrollbar_track(Color::rgb(38, 35, 53)) .scrollbar_thumb(Color::rgb(110, 106, 134)) .build()
1282}
1283
1284fn everforest_theme() -> Theme {
1285 ThemeBuilder::from_theme(themes::dark())
1286 .primary(Color::rgb(167, 192, 128)) .secondary(Color::rgb(214, 153, 182)) .accent(Color::rgb(131, 192, 146)) .background(Color::rgb(39, 46, 34)) .surface(Color::rgb(47, 55, 42)) .overlay(Color::rgb(56, 64, 51)) .text(Color::rgb(211, 198, 170)) .text_muted(Color::rgb(163, 153, 132)) .text_subtle(Color::rgb(125, 117, 100)) .success(Color::rgb(167, 192, 128)) .warning(Color::rgb(219, 188, 127)) .error(Color::rgb(230, 126, 128)) .info(Color::rgb(124, 195, 210)) .border(Color::rgb(68, 77, 60)) .border_focused(Color::rgb(167, 192, 128)) .selection_bg(Color::rgb(167, 192, 128)) .selection_fg(Color::rgb(39, 46, 34)) .scrollbar_track(Color::rgb(47, 55, 42)) .scrollbar_thumb(Color::rgb(125, 117, 100)) .build()
1306}
1307
1308fn kanagawa_theme() -> Theme {
1309 ThemeBuilder::from_theme(themes::dark())
1310 .primary(Color::rgb(127, 180, 202)) .secondary(Color::rgb(149, 127, 184)) .accent(Color::rgb(126, 156, 216)) .background(Color::rgb(31, 31, 40)) .surface(Color::rgb(42, 42, 54)) .overlay(Color::rgb(54, 54, 70)) .text(Color::rgb(220, 215, 186)) .text_muted(Color::rgb(168, 162, 138)) .text_subtle(Color::rgb(114, 113, 105)) .success(Color::rgb(152, 187, 108)) .warning(Color::rgb(255, 169, 98)) .error(Color::rgb(195, 64, 67)) .info(Color::rgb(127, 180, 202)) .border(Color::rgb(84, 84, 109)) .border_focused(Color::rgb(126, 156, 216)) .selection_bg(Color::rgb(73, 65, 107)) .selection_fg(Color::rgb(220, 215, 186)) .scrollbar_track(Color::rgb(42, 42, 54)) .scrollbar_thumb(Color::rgb(84, 84, 109)) .build()
1330}
1331
1332fn ayu_mirage_theme() -> Theme {
1333 ThemeBuilder::from_theme(themes::dark())
1334 .primary(Color::rgb(115, 210, 222)) .secondary(Color::rgb(217, 155, 243)) .accent(Color::rgb(255, 173, 102)) .background(Color::rgb(36, 42, 54)) .surface(Color::rgb(44, 51, 64)) .overlay(Color::rgb(52, 60, 74)) .text(Color::rgb(204, 204, 204)) .text_muted(Color::rgb(150, 155, 160)) .text_subtle(Color::rgb(107, 114, 128)) .success(Color::rgb(135, 213, 134)) .warning(Color::rgb(255, 213, 109)) .error(Color::rgb(240, 113, 120)) .info(Color::rgb(115, 210, 222)) .border(Color::rgb(60, 68, 82)) .border_focused(Color::rgb(115, 210, 222)) .selection_bg(Color::rgb(115, 210, 222)) .selection_fg(Color::rgb(36, 42, 54)) .scrollbar_track(Color::rgb(44, 51, 64)) .scrollbar_thumb(Color::rgb(107, 114, 128)) .build()
1354}
1355
1356fn nightfox_theme() -> Theme {
1357 ThemeBuilder::from_theme(themes::dark())
1358 .primary(Color::rgb(129, 180, 243)) .secondary(Color::rgb(174, 140, 211)) .accent(Color::rgb(99, 205, 207)) .background(Color::rgb(18, 21, 31)) .surface(Color::rgb(29, 33, 46)) .overlay(Color::rgb(41, 46, 62)) .text(Color::rgb(205, 207, 216)) .text_muted(Color::rgb(143, 145, 158)) .text_subtle(Color::rgb(106, 108, 122)) .success(Color::rgb(129, 200, 152)) .warning(Color::rgb(218, 167, 89)) .error(Color::rgb(201, 101, 120)) .info(Color::rgb(99, 205, 207)) .border(Color::rgb(48, 54, 71)) .border_focused(Color::rgb(129, 180, 243)) .selection_bg(Color::rgb(129, 180, 243)) .selection_fg(Color::rgb(18, 21, 31)) .scrollbar_track(Color::rgb(29, 33, 46)) .scrollbar_thumb(Color::rgb(106, 108, 122)) .build()
1378}
1379
1380fn cyberpunk_aurora_theme() -> Theme {
1381 ThemeBuilder::from_theme(themes::dark())
1382 .primary(Color::rgb(255, 0, 128)) .secondary(Color::rgb(0, 255, 255)) .accent(Color::rgb(0, 255, 163)) .background(Color::rgb(13, 2, 33)) .surface(Color::rgb(22, 10, 48)) .overlay(Color::rgb(33, 18, 63)) .text(Color::rgb(224, 210, 255)) .text_muted(Color::rgb(160, 140, 200)) .text_subtle(Color::rgb(120, 100, 160)) .success(Color::rgb(0, 255, 163)) .warning(Color::rgb(255, 213, 0)) .error(Color::rgb(255, 51, 102)) .info(Color::rgb(0, 200, 255)) .border(Color::rgb(60, 30, 100)) .border_focused(Color::rgb(255, 0, 128)) .selection_bg(Color::rgb(255, 0, 128)) .selection_fg(Color::rgb(13, 2, 33)) .scrollbar_track(Color::rgb(22, 10, 48)) .scrollbar_thumb(Color::rgb(120, 100, 160)) .build()
1402}
1403
1404fn synthwave_84_theme() -> Theme {
1405 ThemeBuilder::from_theme(themes::dark())
1406 .primary(Color::rgb(255, 123, 213)) .secondary(Color::rgb(114, 241, 223)) .accent(Color::rgb(254, 215, 102)) .background(Color::rgb(34, 20, 54)) .surface(Color::rgb(44, 28, 68)) .overlay(Color::rgb(54, 36, 82)) .text(Color::rgb(241, 233, 255)) .text_muted(Color::rgb(180, 165, 210)) .text_subtle(Color::rgb(130, 115, 165)) .success(Color::rgb(114, 241, 223)) .warning(Color::rgb(254, 215, 102)) .error(Color::rgb(254, 73, 99)) .info(Color::rgb(54, 245, 253)) .border(Color::rgb(70, 45, 100)) .border_focused(Color::rgb(255, 123, 213)) .selection_bg(Color::rgb(255, 123, 213)) .selection_fg(Color::rgb(34, 20, 54)) .scrollbar_track(Color::rgb(44, 28, 68)) .scrollbar_thumb(Color::rgb(130, 115, 165)) .build()
1426}
1427
1428fn colorblind_theme() -> Theme {
1435 ThemeBuilder::from_theme(tokyo_night_theme())
1436 .primary(Color::rgb(0, 114, 178))
1437 .secondary(Color::rgb(204, 121, 167))
1438 .accent(Color::rgb(230, 159, 0))
1439 .success(Color::rgb(0, 158, 115))
1440 .warning(Color::rgb(240, 228, 66))
1441 .error(Color::rgb(213, 94, 0))
1442 .info(Color::rgb(86, 180, 233))
1443 .build()
1444}
1445
1446fn apply_a11y_overrides(theme: Theme) -> Theme {
1447 ThemeBuilder::from_theme(theme)
1448 .border_focused(Color::rgb(255, 255, 0))
1449 .selection_bg(AdaptiveColor::adaptive(
1450 Color::rgb(0, 0, 0),
1451 Color::rgb(255, 255, 255),
1452 ))
1453 .selection_fg(AdaptiveColor::adaptive(
1454 Color::rgb(255, 255, 255),
1455 Color::rgb(0, 0, 0),
1456 ))
1457 .build()
1458}
1459
1460fn downgrade_adaptive_color(color: AdaptiveColor, profile: ColorProfile) -> AdaptiveColor {
1461 match color {
1462 AdaptiveColor::Fixed(value) => AdaptiveColor::fixed(value.downgrade(profile)),
1463 AdaptiveColor::Adaptive { light, dark } => {
1464 AdaptiveColor::adaptive(light.downgrade(profile), dark.downgrade(profile))
1465 }
1466 }
1467}
1468
1469fn downgrade_theme_for_profile(theme: Theme, profile: ColorProfile) -> Theme {
1470 if profile == ColorProfile::TrueColor {
1471 return theme;
1472 }
1473
1474 Theme {
1475 primary: downgrade_adaptive_color(theme.primary, profile),
1476 secondary: downgrade_adaptive_color(theme.secondary, profile),
1477 accent: downgrade_adaptive_color(theme.accent, profile),
1478 background: downgrade_adaptive_color(theme.background, profile),
1479 surface: downgrade_adaptive_color(theme.surface, profile),
1480 overlay: downgrade_adaptive_color(theme.overlay, profile),
1481 text: downgrade_adaptive_color(theme.text, profile),
1482 text_muted: downgrade_adaptive_color(theme.text_muted, profile),
1483 text_subtle: downgrade_adaptive_color(theme.text_subtle, profile),
1484 success: downgrade_adaptive_color(theme.success, profile),
1485 warning: downgrade_adaptive_color(theme.warning, profile),
1486 error: downgrade_adaptive_color(theme.error, profile),
1487 info: downgrade_adaptive_color(theme.info, profile),
1488 border: downgrade_adaptive_color(theme.border, profile),
1489 border_focused: downgrade_adaptive_color(theme.border_focused, profile),
1490 selection_bg: downgrade_adaptive_color(theme.selection_bg, profile),
1491 selection_fg: downgrade_adaptive_color(theme.selection_fg, profile),
1492 scrollbar_track: downgrade_adaptive_color(theme.scrollbar_track, profile),
1493 scrollbar_thumb: downgrade_adaptive_color(theme.scrollbar_thumb, profile),
1494 }
1495}
1496
1497fn build_stylesheet(resolved: ResolvedTheme, options: StyleOptions) -> StyleSheet {
1521 let sheet = StyleSheet::new();
1522
1523 let zebra_bg = if options.gradients_enabled() {
1524 blend(resolved.surface, resolved.background, 0.35).downgrade(options.color_profile)
1525 } else {
1526 resolved.surface
1527 };
1528
1529 let role_user = blend(resolved.accent, resolved.success, 0.35);
1534 let role_assistant = resolved.info;
1535 let role_tool = resolved.warning;
1536 let role_system = resolved.error;
1537
1538 sheet.define(
1539 STYLE_APP_ROOT,
1540 Style::new()
1541 .fg(to_packed(resolved.text))
1542 .bg(to_packed(resolved.background)),
1543 );
1544 sheet.define(
1545 STYLE_PANE_BASE,
1546 Style::new()
1547 .fg(to_packed(resolved.text))
1548 .bg(to_packed(resolved.surface)),
1549 );
1550 sheet.define(
1551 STYLE_PANE_FOCUSED,
1552 Style::new()
1553 .fg(to_packed(resolved.border_focused))
1554 .bg(to_packed(resolved.surface)),
1555 );
1556 sheet.define(
1559 STYLE_PANE_TITLE_FOCUSED,
1560 Style::new()
1561 .fg(to_packed(resolved.accent))
1562 .bg(to_packed(resolved.surface))
1563 .bold(),
1564 );
1565 sheet.define(
1566 STYLE_PANE_TITLE_UNFOCUSED,
1567 Style::new()
1568 .fg(to_packed(resolved.text_muted))
1569 .bg(to_packed(resolved.surface)),
1570 );
1571 sheet.define(
1573 STYLE_SPLIT_HANDLE,
1574 Style::new()
1575 .fg(to_packed(resolved.border))
1576 .bg(to_packed(resolved.background)),
1577 );
1578
1579 sheet.define(
1580 STYLE_TEXT_PRIMARY,
1581 Style::new().fg(to_packed(resolved.text)),
1582 );
1583 sheet.define(
1584 STYLE_TEXT_MUTED,
1585 Style::new().fg(to_packed(resolved.text_muted)),
1586 );
1587 sheet.define(
1588 STYLE_TEXT_SUBTLE,
1589 Style::new().fg(to_packed(resolved.text_subtle)),
1590 );
1591
1592 sheet.define(
1593 STYLE_STATUS_SUCCESS,
1594 Style::new().fg(to_packed(resolved.success)).bold(),
1595 );
1596 sheet.define(
1597 STYLE_STATUS_WARNING,
1598 Style::new().fg(to_packed(resolved.warning)).bold(),
1599 );
1600 sheet.define(
1601 STYLE_STATUS_ERROR,
1602 Style::new().fg(to_packed(resolved.error)).bold(),
1603 );
1604 sheet.define(
1605 STYLE_STATUS_INFO,
1606 Style::new().fg(to_packed(resolved.info)).bold(),
1607 );
1608
1609 sheet.define(
1610 STYLE_RESULT_ROW,
1611 Style::new()
1612 .fg(to_packed(resolved.text))
1613 .bg(to_packed(resolved.surface)),
1614 );
1615 sheet.define(
1616 STYLE_RESULT_ROW_ALT,
1617 Style::new()
1618 .fg(to_packed(resolved.text))
1619 .bg(to_packed(zebra_bg)),
1620 );
1621
1622 let selected_style = if options.a11y {
1623 Style::new()
1624 .fg(to_packed(resolved.selection_fg))
1625 .bg(to_packed(resolved.selection_bg))
1626 .bold()
1627 .underline()
1628 } else {
1629 Style::new()
1630 .fg(to_packed(resolved.selection_fg))
1631 .bg(to_packed(resolved.selection_bg))
1632 .bold()
1633 };
1634 sheet.define(STYLE_RESULT_ROW_SELECTED, selected_style);
1635
1636 let role_user_style = if options.a11y {
1637 Style::new().fg(to_packed(role_user)).bold().underline()
1638 } else {
1639 Style::new().fg(to_packed(role_user)).bold()
1640 };
1641 let role_assistant_style = if options.a11y {
1642 Style::new().fg(to_packed(role_assistant)).bold().italic()
1643 } else {
1644 Style::new().fg(to_packed(role_assistant)).bold()
1645 };
1646 let role_tool_style = if options.a11y {
1647 Style::new().fg(to_packed(role_tool)).underline()
1648 } else {
1649 Style::new().fg(to_packed(role_tool))
1650 };
1651 let role_system_style = if options.a11y {
1652 Style::new().fg(to_packed(role_system)).bold().underline()
1653 } else {
1654 Style::new().fg(to_packed(role_system)).bold()
1655 };
1656
1657 sheet.define(STYLE_ROLE_USER, role_user_style);
1658 sheet.define(STYLE_ROLE_ASSISTANT, role_assistant_style);
1659 sheet.define(STYLE_ROLE_TOOL, role_tool_style);
1660 sheet.define(STYLE_ROLE_SYSTEM, role_system_style);
1661
1662 sheet.define(
1663 STYLE_ROLE_GUTTER_USER,
1664 Style::new().fg(to_packed(role_user)).bg(to_packed(blend(
1665 resolved.background,
1666 role_user,
1667 0.18,
1668 ))),
1669 );
1670 sheet.define(
1671 STYLE_ROLE_GUTTER_ASSISTANT,
1672 Style::new()
1673 .fg(to_packed(role_assistant))
1674 .bg(to_packed(blend(resolved.background, role_assistant, 0.18))),
1675 );
1676 sheet.define(
1677 STYLE_ROLE_GUTTER_TOOL,
1678 Style::new().fg(to_packed(role_tool)).bg(to_packed(blend(
1679 resolved.background,
1680 role_tool,
1681 0.18,
1682 ))),
1683 );
1684 sheet.define(
1685 STYLE_ROLE_GUTTER_SYSTEM,
1686 Style::new().fg(to_packed(role_system)).bg(to_packed(blend(
1687 resolved.background,
1688 role_system,
1689 0.18,
1690 ))),
1691 );
1692
1693 sheet.define(
1694 STYLE_SCORE_HIGH,
1695 Style::new().fg(to_packed(resolved.success)).bold(),
1696 );
1697 sheet.define(
1698 STYLE_SCORE_MID,
1699 Style::new().fg(to_packed(resolved.info)).bold(),
1700 );
1701 let score_low_fg = blend(resolved.text_subtle, resolved.background, 0.35);
1703 sheet.define(
1704 STYLE_SCORE_LOW,
1705 Style::new().fg(to_packed(score_low_fg)).dim(),
1706 );
1707
1708 sheet.define(
1711 STYLE_SOURCE_LOCAL,
1712 Style::new().fg(to_packed(resolved.text_muted)),
1713 );
1714 sheet.define(
1715 STYLE_SOURCE_REMOTE,
1716 Style::new().fg(to_packed(resolved.info)).italic(),
1717 );
1718 sheet.define(
1720 STYLE_LOCATION,
1721 Style::new().fg(to_packed(resolved.text_subtle)),
1722 );
1723
1724 sheet.define(
1725 STYLE_KBD_KEY,
1726 Style::new().fg(to_packed(resolved.accent)).bold(),
1727 );
1728 sheet.define(
1729 STYLE_KBD_DESC,
1730 Style::new().fg(to_packed(resolved.text_subtle)),
1731 );
1732
1733 let pill_active_bg = blend(resolved.surface, resolved.info, 0.35);
1734 sheet.define(
1735 STYLE_PILL_ACTIVE,
1736 Style::new()
1737 .fg(to_packed(resolved.accent))
1738 .bg(to_packed(pill_active_bg))
1739 .bold(),
1740 );
1741 sheet.define(
1742 STYLE_PILL_INACTIVE,
1743 Style::new()
1744 .fg(to_packed(resolved.text_muted))
1745 .bg(to_packed(blend(resolved.surface, resolved.border, 0.12)))
1746 .dim(),
1747 );
1748 sheet.define(
1749 STYLE_PILL_LABEL,
1750 Style::new()
1751 .fg(to_packed(blend(resolved.text_muted, resolved.text, 0.35)))
1752 .bg(to_packed(pill_active_bg))
1753 .bold(),
1754 );
1755
1756 sheet.define(
1757 STYLE_CRUMB_ACTIVE,
1758 Style::new().fg(to_packed(resolved.accent)).bold(),
1759 );
1760 sheet.define(
1761 STYLE_CRUMB_INACTIVE,
1762 Style::new().fg(to_packed(resolved.text_subtle)),
1763 );
1764 sheet.define(
1765 STYLE_CRUMB_SEPARATOR,
1766 Style::new().fg(to_packed(resolved.border)),
1767 );
1768
1769 sheet.define(
1770 STYLE_TAB_ACTIVE,
1771 Style::new()
1772 .fg(to_packed(resolved.accent))
1773 .bg(to_packed(blend(resolved.surface, resolved.info, 0.15)))
1774 .bold()
1775 .underline(),
1776 );
1777 sheet.define(
1778 STYLE_TAB_INACTIVE,
1779 Style::new().fg(to_packed(resolved.text_muted)).underline(),
1780 );
1781 sheet.define(
1782 STYLE_DETAIL_FIND_CONTAINER,
1783 Style::new()
1784 .fg(to_packed(resolved.text))
1785 .bg(to_packed(blend(resolved.overlay, resolved.border, 0.30))),
1786 );
1787 sheet.define(
1788 STYLE_DETAIL_FIND_QUERY,
1789 Style::new()
1790 .fg(to_packed(resolved.accent))
1791 .bold()
1792 .underline(),
1793 );
1794 sheet.define(
1795 STYLE_DETAIL_FIND_MATCH_ACTIVE,
1796 Style::new()
1797 .fg(to_packed(resolved.selection_fg))
1798 .bg(to_packed(resolved.selection_bg))
1799 .bold(),
1800 );
1801 sheet.define(
1802 STYLE_DETAIL_FIND_MATCH_INACTIVE,
1803 Style::new()
1804 .fg(to_packed(resolved.text_muted))
1805 .bg(to_packed(blend(
1806 resolved.surface,
1807 resolved.border_focused,
1808 0.28,
1809 ))),
1810 );
1811 sheet.define(
1812 STYLE_QUERY_HIGHLIGHT,
1813 Style::new()
1814 .fg(to_packed(resolved.accent))
1815 .bold()
1816 .underline(),
1817 );
1818
1819 sheet.define(
1821 STYLE_SEARCH_FOCUS,
1822 Style::new()
1823 .fg(to_packed(resolved.accent))
1824 .bg(to_packed(blend(resolved.surface, resolved.accent, 0.18)))
1825 .bold(),
1826 );
1827
1828 sheet.define(
1830 STYLE_MODAL_BACKDROP,
1831 Style::new().bg(to_packed(blend(
1832 resolved.background,
1833 Color::rgb(0, 0, 0),
1834 0.45,
1835 ))),
1836 );
1837
1838 sheet
1839}
1840
1841fn to_packed(color: Color) -> PackedRgba {
1842 let rgb = color.to_rgb();
1843 PackedRgba::rgb(rgb.r, rgb.g, rgb.b)
1844}
1845
1846fn contrast_check(pair: &'static str, fg: Color, bg: Color, minimum: f64) -> ThemeContrastCheck {
1847 let ratio = ftui::style::contrast_ratio_packed(to_packed(fg), to_packed(bg));
1848 ThemeContrastCheck {
1849 pair,
1850 ratio,
1851 minimum,
1852 passes: ratio >= minimum,
1853 }
1854}
1855
1856fn build_contrast_report(resolved: ResolvedTheme) -> ThemeContrastReport {
1857 ThemeContrastReport {
1858 checks: vec![
1859 contrast_check("text/background", resolved.text, resolved.background, 3.0),
1860 contrast_check("text/surface", resolved.text, resolved.surface, 2.5),
1861 contrast_check(
1862 "selection_fg/selection_bg",
1863 resolved.selection_fg,
1864 resolved.selection_bg,
1865 3.0,
1866 ),
1867 contrast_check(
1868 "text_muted/background",
1869 resolved.text_muted,
1870 resolved.background,
1871 3.0,
1872 ),
1873 contrast_check(
1874 "border_focused/background",
1875 resolved.border_focused,
1876 resolved.background,
1877 3.0,
1878 ),
1879 ],
1880 }
1881}
1882
1883fn blend(a: Color, b: Color, t: f32) -> Color {
1884 let t = t.clamp(0.0, 1.0);
1885 let ar = a.to_rgb();
1886 let br = b.to_rgb();
1887
1888 let blend_channel = |left: u8, right: u8| -> u8 {
1889 let mixed = left as f32 + (right as f32 - left as f32) * t;
1890 mixed.round().clamp(0.0, 255.0) as u8
1891 };
1892
1893 Color::rgb(
1894 blend_channel(ar.r, br.r),
1895 blend_channel(ar.g, br.g),
1896 blend_channel(ar.b, br.b),
1897 )
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902 use super::*;
1903 use std::time::{SystemTime, UNIX_EPOCH};
1904
1905 #[test]
1906 fn preset_parse_and_cycles_are_stable() {
1907 assert_eq!(
1908 UiThemePreset::parse("dark"),
1909 Some(UiThemePreset::TokyoNight)
1910 );
1911 assert_eq!(UiThemePreset::parse("light"), Some(UiThemePreset::Daylight));
1912 assert_eq!(
1913 UiThemePreset::parse("catppuccin"),
1914 Some(UiThemePreset::Catppuccin)
1915 );
1916 assert_eq!(
1917 UiThemePreset::parse("dracula"),
1918 Some(UiThemePreset::Dracula)
1919 );
1920 assert_eq!(UiThemePreset::parse("nord"), Some(UiThemePreset::Nord));
1921 assert_eq!(
1922 UiThemePreset::parse("high_contrast"),
1923 Some(UiThemePreset::HighContrast)
1924 );
1925 assert_eq!(
1926 UiThemePreset::parse("gruvbox"),
1927 Some(UiThemePreset::GruvboxDark)
1928 );
1929 assert_eq!(
1930 UiThemePreset::parse("rose-pine"),
1931 Some(UiThemePreset::RosePine)
1932 );
1933
1934 assert_eq!(UiThemePreset::TokyoNight.next(), UiThemePreset::Daylight);
1935 assert_eq!(
1936 UiThemePreset::Daylight.previous(),
1937 UiThemePreset::TokyoNight
1938 );
1939 assert_eq!(
1940 UiThemePreset::TokyoNight.previous(),
1941 UiThemePreset::Colorblind
1942 );
1943 }
1944
1945 #[test]
1946 fn options_from_values_honor_opt_out_and_profile_override() {
1947 let options = StyleOptions::from_env_values(EnvValues {
1948 no_color: Some("1"),
1949 cass_respect_no_color: Some("1"),
1950 cass_no_color: None,
1951 colorterm: Some("truecolor"),
1952 term: Some("xterm-256color"),
1953 cass_no_icons: Some("1"),
1954 cass_no_gradient: Some("1"),
1955 cass_a11y: Some("true"),
1956 cass_theme: Some("nord"),
1957 cass_color_profile: Some("ansi16"),
1958 });
1959
1960 assert_eq!(options.preset, UiThemePreset::Nord);
1961 assert!(options.no_color);
1962 assert!(options.no_icons);
1963 assert!(options.no_gradient);
1964 assert!(options.a11y);
1965 assert_eq!(options.color_profile, ColorProfile::Mono);
1966 }
1967
1968 #[test]
1969 fn options_profile_override_applies_when_color_enabled() {
1970 let options = StyleOptions::from_env_values(EnvValues {
1971 no_color: None,
1972 cass_respect_no_color: None,
1973 cass_no_color: None,
1974 colorterm: Some("truecolor"),
1975 term: Some("xterm-256color"),
1976 cass_no_icons: None,
1977 cass_no_gradient: None,
1978 cass_a11y: Some("0"),
1979 cass_theme: Some("dark"),
1980 cass_color_profile: Some("ansi16"),
1981 });
1982
1983 assert_eq!(options.color_profile, ColorProfile::Ansi16);
1984 assert!(!options.no_color);
1985 }
1986
1987 #[test]
1988 fn options_ignore_no_color_unless_explicitly_requested() {
1989 let options = StyleOptions::from_env_values(EnvValues {
1990 no_color: Some("1"),
1991 cass_respect_no_color: None,
1992 cass_no_color: None,
1993 colorterm: Some("truecolor"),
1994 term: Some("xterm-256color"),
1995 cass_no_icons: None,
1996 cass_no_gradient: None,
1997 cass_a11y: Some("0"),
1998 cass_theme: Some("dark"),
1999 cass_color_profile: None,
2000 });
2001
2002 assert!(!options.no_color);
2003 assert_eq!(options.color_profile, ColorProfile::TrueColor);
2004 }
2005
2006 #[test]
2007 fn cass_no_color_always_forces_monochrome() {
2008 let options = StyleOptions::from_env_values(EnvValues {
2009 no_color: None,
2010 cass_respect_no_color: None,
2011 cass_no_color: Some("1"),
2012 colorterm: Some("truecolor"),
2013 term: Some("xterm-256color"),
2014 cass_no_icons: None,
2015 cass_no_gradient: None,
2016 cass_a11y: Some("0"),
2017 cass_theme: Some("dark"),
2018 cass_color_profile: Some("truecolor"),
2019 });
2020
2021 assert!(options.no_color);
2022 assert_eq!(options.color_profile, ColorProfile::Mono);
2023 }
2024
2025 #[test]
2026 fn cass_no_color_falsy_values_do_not_force_monochrome() {
2027 for falsy in &["0", "false", "off", "no"] {
2028 let options = StyleOptions::from_env_values(EnvValues {
2029 no_color: None,
2030 cass_respect_no_color: None,
2031 cass_no_color: Some(falsy),
2032 colorterm: Some("truecolor"),
2033 term: Some("xterm-256color"),
2034 cass_no_icons: None,
2035 cass_no_gradient: None,
2036 cass_a11y: Some("0"),
2037 cass_theme: Some("dark"),
2038 cass_color_profile: None,
2039 });
2040 assert!(
2041 !options.no_color,
2042 "CASS_NO_COLOR={falsy} must not force monochrome"
2043 );
2044 assert_eq!(
2045 options.color_profile,
2046 ColorProfile::TrueColor,
2047 "CASS_NO_COLOR={falsy} should preserve detected profile"
2048 );
2049 }
2050 }
2051
2052 #[test]
2055 fn no_color_without_respect_flag_preserves_full_color() {
2056 let options = StyleOptions::from_env_values(EnvValues {
2058 no_color: Some("1"),
2059 cass_respect_no_color: None,
2060 cass_no_color: None,
2061 colorterm: None,
2062 term: None,
2063 cass_no_icons: None,
2064 cass_no_gradient: None,
2065 cass_a11y: None,
2066 cass_theme: None,
2067 cass_color_profile: None,
2068 });
2069 assert!(!options.no_color, "NO_COLOR alone must be ignored");
2070 assert!(!options.no_gradient, "gradient should remain enabled");
2071 }
2072
2073 #[test]
2074 fn respect_no_color_with_falsy_value_is_not_truthy() {
2075 for falsy in &["0", "false", "off", "no"] {
2077 let options = StyleOptions::from_env_values(EnvValues {
2078 no_color: Some("1"),
2079 cass_respect_no_color: Some(falsy),
2080 cass_no_color: None,
2081 colorterm: Some("truecolor"),
2082 term: None,
2083 cass_no_icons: None,
2084 cass_no_gradient: None,
2085 cass_a11y: None,
2086 cass_theme: None,
2087 cass_color_profile: None,
2088 });
2089 assert!(
2090 !options.no_color,
2091 "CASS_RESPECT_NO_COLOR={falsy} must be falsy"
2092 );
2093 assert_eq!(options.color_profile, ColorProfile::TrueColor);
2094 }
2095 }
2096
2097 #[test]
2098 fn invalid_color_profile_falls_back_to_env_detection() {
2099 let options = StyleOptions::from_env_values(EnvValues {
2100 no_color: None,
2101 cass_respect_no_color: None,
2102 cass_no_color: None,
2103 colorterm: Some("truecolor"),
2104 term: Some("xterm-256color"),
2105 cass_no_icons: None,
2106 cass_no_gradient: None,
2107 cass_a11y: None,
2108 cass_theme: None,
2109 cass_color_profile: Some("garbage-value"),
2110 });
2111 assert_eq!(options.color_profile, ColorProfile::TrueColor);
2113 assert!(!options.no_color);
2114 }
2115
2116 #[test]
2117 fn a11y_cascades_no_gradient() {
2118 let options = StyleOptions::from_env_values(EnvValues {
2120 no_color: None,
2121 cass_respect_no_color: None,
2122 cass_no_color: None,
2123 colorterm: Some("truecolor"),
2124 term: None,
2125 cass_no_icons: None,
2126 cass_no_gradient: None,
2127 cass_a11y: Some("1"),
2128 cass_theme: None,
2129 cass_color_profile: None,
2130 });
2131 assert!(options.a11y);
2132 assert!(
2133 options.no_gradient,
2134 "a11y must cascade into no_gradient=true"
2135 );
2136 assert!(!options.no_color, "a11y must not cascade into no_color");
2137 assert_eq!(
2138 options.color_profile,
2139 ColorProfile::TrueColor,
2140 "a11y must not downgrade color profile"
2141 );
2142 }
2143
2144 #[test]
2145 fn no_icons_is_independent_of_color_state() {
2146 let with_icons_off = StyleOptions::from_env_values(EnvValues {
2148 no_color: None,
2149 cass_respect_no_color: None,
2150 cass_no_color: None,
2151 colorterm: Some("truecolor"),
2152 term: None,
2153 cass_no_icons: Some("1"),
2154 cass_no_gradient: None,
2155 cass_a11y: None,
2156 cass_theme: None,
2157 cass_color_profile: None,
2158 });
2159 assert!(with_icons_off.no_icons);
2160 assert!(!with_icons_off.no_color);
2161 assert_eq!(with_icons_off.color_profile, ColorProfile::TrueColor);
2162 }
2163
2164 #[test]
2165 fn no_icons_falsy_values_do_not_disable_icons() {
2166 for falsy in &["0", "false", "off", "no"] {
2167 let options = StyleOptions::from_env_values(EnvValues {
2168 no_color: None,
2169 cass_respect_no_color: None,
2170 cass_no_color: None,
2171 colorterm: Some("truecolor"),
2172 term: Some("xterm-256color"),
2173 cass_no_icons: Some(falsy),
2174 cass_no_gradient: None,
2175 cass_a11y: Some("0"),
2176 cass_theme: Some("dark"),
2177 cass_color_profile: None,
2178 });
2179 assert!(
2180 !options.no_icons,
2181 "CASS_NO_ICONS={falsy} should keep icons enabled"
2182 );
2183 }
2184 }
2185
2186 #[test]
2187 fn no_gradient_falsy_values_do_not_disable_gradients() {
2188 for falsy in &["0", "false", "off", "no"] {
2189 let options = StyleOptions::from_env_values(EnvValues {
2190 no_color: None,
2191 cass_respect_no_color: None,
2192 cass_no_color: None,
2193 colorterm: Some("truecolor"),
2194 term: Some("xterm-256color"),
2195 cass_no_icons: None,
2196 cass_no_gradient: Some(falsy),
2197 cass_a11y: Some("0"),
2198 cass_theme: Some("dark"),
2199 cass_color_profile: None,
2200 });
2201 assert!(
2202 !options.no_gradient,
2203 "CASS_NO_GRADIENT={falsy} should keep gradients enabled"
2204 );
2205 assert!(
2206 options.gradients_enabled(),
2207 "gradients should remain enabled when CASS_NO_GRADIENT is falsy"
2208 );
2209 }
2210 }
2211
2212 #[test]
2213 fn dark_mode_follows_preset() {
2214 let presets_and_expected = [
2216 ("dark", true),
2217 ("light", false),
2218 ("nord", true),
2219 ("cat", true),
2220 ("dracula", true),
2221 ("solarized-light", false),
2222 ("solarized-dark", true),
2223 ("gruvbox", true),
2224 ];
2225 for (name, expected_dark) in presets_and_expected {
2226 let options = StyleOptions::from_env_values(EnvValues {
2227 cass_theme: Some(name),
2228 ..EnvValues::default()
2229 });
2230 assert_eq!(
2231 options.dark_mode, expected_dark,
2232 "preset {name}: expected dark_mode={expected_dark}"
2233 );
2234 }
2235 }
2236
2237 #[test]
2238 fn unknown_theme_falls_back_to_tokyo_night() {
2239 let options = StyleOptions::from_env_values(EnvValues {
2240 cass_theme: Some("nonexistent"),
2241 ..EnvValues::default()
2242 });
2243 assert_eq!(options.preset, UiThemePreset::TokyoNight);
2244 assert!(options.dark_mode);
2245 }
2246
2247 #[test]
2248 fn gradients_enabled_requires_color_support() {
2249 let mono = StyleOptions {
2251 color_profile: ColorProfile::Mono,
2252 no_gradient: false,
2253 ..StyleOptions::default()
2254 };
2255 assert!(!mono.gradients_enabled());
2256
2257 let no_grad = StyleOptions {
2259 color_profile: ColorProfile::TrueColor,
2260 no_gradient: true,
2261 ..StyleOptions::default()
2262 };
2263 assert!(!no_grad.gradients_enabled());
2264
2265 let full = StyleOptions {
2267 color_profile: ColorProfile::TrueColor,
2268 no_gradient: false,
2269 ..StyleOptions::default()
2270 };
2271 assert!(full.gradients_enabled());
2272 }
2273
2274 #[test]
2275 fn env_truthy_edge_cases() {
2276 assert!(!env_truthy(None), "None → false");
2278 assert!(
2279 !env_truthy(Some("")),
2280 "empty string → false (treated as unset)"
2281 );
2282 assert!(env_truthy(Some("1")), "\"1\" → true");
2283 assert!(env_truthy(Some("yes")), "\"yes\" → true");
2284 assert!(env_truthy(Some("true")), "\"true\" → true... wait");
2285 assert!(!env_truthy(Some("false")), "\"false\" → false");
2288 assert!(
2289 !env_truthy(Some("FALSE")),
2290 "\"FALSE\" → false (case insensitive)"
2291 );
2292 assert!(!env_truthy(Some(" Off ")), "trimmed \"Off\" → false");
2293 assert!(!env_truthy(Some("NO")), "\"NO\" → false");
2294 assert!(env_truthy(Some("anything")), "arbitrary string → true");
2295 }
2296
2297 #[test]
2298 fn env_precedence_full_matrix() {
2299 let p1 = StyleOptions::from_env_values(EnvValues {
2303 cass_no_color: Some("1"),
2304 cass_color_profile: Some("truecolor"),
2305 colorterm: Some("truecolor"),
2306 ..EnvValues::default()
2307 });
2308 assert!(p1.no_color);
2309 assert_eq!(p1.color_profile, ColorProfile::Mono);
2310
2311 let p2 = StyleOptions::from_env_values(EnvValues {
2313 no_color: Some("1"),
2314 cass_respect_no_color: Some("1"),
2315 cass_color_profile: Some("truecolor"),
2316 ..EnvValues::default()
2317 });
2318 assert!(p2.no_color);
2319 assert_eq!(p2.color_profile, ColorProfile::Mono);
2320
2321 let p3 = StyleOptions::from_env_values(EnvValues {
2323 colorterm: Some("truecolor"),
2324 term: Some("xterm-256color"),
2325 cass_color_profile: Some("ansi16"),
2326 ..EnvValues::default()
2327 });
2328 assert!(!p3.no_color);
2329 assert_eq!(p3.color_profile, ColorProfile::Ansi16);
2330
2331 let p4 = StyleOptions::from_env_values(EnvValues {
2333 colorterm: Some("truecolor"),
2334 term: Some("xterm-256color"),
2335 ..EnvValues::default()
2336 });
2337 assert!(!p4.no_color);
2338 assert_eq!(p4.color_profile, ColorProfile::TrueColor);
2339
2340 let bare = StyleOptions::from_env_values(EnvValues::default());
2342 assert!(!bare.no_color);
2343 assert_eq!(bare.preset, UiThemePreset::TokyoNight);
2344 assert!(bare.dark_mode);
2345 }
2346
2347 #[test]
2348 fn style_context_mono_produces_no_fg_bg() {
2349 let ctx = StyleContext::from_options(StyleOptions {
2352 preset: UiThemePreset::TokyoNight,
2353 dark_mode: true,
2354 color_profile: ColorProfile::Mono,
2355 no_color: true,
2356 no_icons: false,
2357 no_gradient: true,
2358 a11y: false,
2359 });
2360 let _ = ctx.style(STYLE_TEXT_PRIMARY);
2362 let _ = ctx.style(STYLE_APP_ROOT);
2363 let _ = ctx.style(STYLE_ROLE_USER);
2364 let _ = ctx.style(STYLE_SCORE_HIGH);
2365 }
2366
2367 #[test]
2368 fn all_presets_produce_valid_style_context() {
2369 for preset in UiThemePreset::all() {
2372 for &profile in &[
2373 ColorProfile::TrueColor,
2374 ColorProfile::Ansi256,
2375 ColorProfile::Ansi16,
2376 ColorProfile::Mono,
2377 ] {
2378 let dark_mode = !matches!(
2379 preset,
2380 UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2381 );
2382 let ctx = StyleContext::from_options(StyleOptions {
2383 preset,
2384 dark_mode,
2385 color_profile: profile,
2386 no_color: profile == ColorProfile::Mono,
2387 no_icons: false,
2388 no_gradient: profile == ColorProfile::Mono,
2389 a11y: false,
2390 });
2391 for &(_, token) in ALL_STYLE_TOKENS {
2393 let _ = ctx.style(token);
2394 }
2395 }
2396 }
2397 }
2398
2399 #[test]
2400 fn dark_preset_matches_tokyo_night_palette() {
2401 let context = StyleContext::from_options(StyleOptions {
2402 preset: UiThemePreset::TokyoNight,
2403 dark_mode: true,
2404 color_profile: ColorProfile::TrueColor,
2405 no_color: false,
2406 no_icons: false,
2407 no_gradient: false,
2408 a11y: false,
2409 });
2410
2411 assert_eq!(context.resolved.background, Color::rgb(26, 27, 38));
2412 assert_eq!(context.resolved.surface, Color::rgb(36, 40, 59));
2413 assert_eq!(context.resolved.text, Color::rgb(192, 202, 245));
2414 assert_eq!(context.resolved.border_focused, Color::rgb(125, 145, 200));
2415 }
2416
2417 #[test]
2418 fn style_context_builds_required_semantic_styles() {
2419 let context = StyleContext::from_options(StyleOptions {
2420 preset: UiThemePreset::TokyoNight,
2421 dark_mode: true,
2422 color_profile: ColorProfile::TrueColor,
2423 no_color: false,
2424 no_icons: false,
2425 no_gradient: false,
2426 a11y: false,
2427 });
2428
2429 for key in [
2430 STYLE_APP_ROOT,
2431 STYLE_PANE_BASE,
2432 STYLE_PANE_FOCUSED,
2433 STYLE_PANE_TITLE_FOCUSED,
2434 STYLE_PANE_TITLE_UNFOCUSED,
2435 STYLE_SPLIT_HANDLE,
2436 STYLE_RESULT_ROW,
2437 STYLE_RESULT_ROW_ALT,
2438 STYLE_RESULT_ROW_SELECTED,
2439 STYLE_ROLE_USER,
2440 STYLE_ROLE_ASSISTANT,
2441 STYLE_ROLE_TOOL,
2442 STYLE_ROLE_SYSTEM,
2443 STYLE_ROLE_GUTTER_USER,
2444 STYLE_ROLE_GUTTER_ASSISTANT,
2445 STYLE_ROLE_GUTTER_TOOL,
2446 STYLE_ROLE_GUTTER_SYSTEM,
2447 STYLE_SCORE_HIGH,
2448 STYLE_SCORE_MID,
2449 STYLE_SCORE_LOW,
2450 STYLE_SOURCE_LOCAL,
2451 STYLE_SOURCE_REMOTE,
2452 STYLE_LOCATION,
2453 STYLE_KBD_KEY,
2454 STYLE_KBD_DESC,
2455 STYLE_PILL_ACTIVE,
2456 STYLE_PILL_INACTIVE,
2457 STYLE_PILL_LABEL,
2458 STYLE_CRUMB_ACTIVE,
2459 STYLE_CRUMB_INACTIVE,
2460 STYLE_CRUMB_SEPARATOR,
2461 STYLE_TAB_ACTIVE,
2462 STYLE_TAB_INACTIVE,
2463 STYLE_DETAIL_FIND_CONTAINER,
2464 STYLE_DETAIL_FIND_QUERY,
2465 STYLE_DETAIL_FIND_MATCH_ACTIVE,
2466 STYLE_DETAIL_FIND_MATCH_INACTIVE,
2467 STYLE_QUERY_HIGHLIGHT,
2468 STYLE_SEARCH_FOCUS,
2469 STYLE_MODAL_BACKDROP,
2470 ] {
2471 assert!(context.sheet.contains(key), "missing style token: {key}");
2472 }
2473 }
2474
2475 #[test]
2476 fn detail_find_token_hierarchy_is_explicit_and_theme_aware() {
2477 for preset in UiThemePreset::all() {
2478 let ctx = context_for_preset(preset);
2479 let container = ctx.style(STYLE_DETAIL_FIND_CONTAINER);
2480 let query = ctx.style(STYLE_DETAIL_FIND_QUERY);
2481 let active = ctx.style(STYLE_DETAIL_FIND_MATCH_ACTIVE);
2482 let inactive = ctx.style(STYLE_DETAIL_FIND_MATCH_INACTIVE);
2483
2484 assert!(
2485 container.bg.is_some(),
2486 "find container should provide a distinct background for preset {}",
2487 preset.name()
2488 );
2489 assert!(
2490 query == query.bold(),
2491 "find query should be emphasized (bold) for preset {}",
2492 preset.name()
2493 );
2494 assert!(
2495 active == active.bold() && active.bg.is_some(),
2496 "active match state should be high-emphasis for preset {}",
2497 preset.name()
2498 );
2499 assert!(
2500 inactive.fg.is_some(),
2501 "inactive match counter should still be legible for preset {}",
2502 preset.name()
2503 );
2504 assert_ne!(
2505 format!("{:?}", active),
2506 format!("{:?}", inactive),
2507 "active/inactive match states must be visually distinct for preset {}",
2508 preset.name()
2509 );
2510 }
2511 }
2512
2513 #[test]
2514 fn detail_find_tokens_remain_legible_in_mono_mode() {
2515 let ctx = StyleContext::from_options(StyleOptions {
2516 preset: UiThemePreset::TokyoNight,
2517 dark_mode: true,
2518 color_profile: ColorProfile::Mono,
2519 no_color: true,
2520 no_icons: false,
2521 no_gradient: true,
2522 a11y: false,
2523 });
2524
2525 for (label, token) in [
2526 ("container", STYLE_DETAIL_FIND_CONTAINER),
2527 ("query", STYLE_DETAIL_FIND_QUERY),
2528 ("match_active", STYLE_DETAIL_FIND_MATCH_ACTIVE),
2529 ("match_inactive", STYLE_DETAIL_FIND_MATCH_INACTIVE),
2530 ] {
2531 let style = ctx.style(token);
2532 assert!(
2533 style.fg.is_some() || style.bg.is_some(),
2534 "detail-find {label} token should remain visible in mono mode"
2535 );
2536 }
2537 }
2538
2539 #[test]
2540 fn mono_profile_downgrades_theme_colors() {
2541 let context = StyleContext::from_options(StyleOptions {
2542 preset: UiThemePreset::Dracula,
2543 dark_mode: true,
2544 color_profile: ColorProfile::Mono,
2545 no_color: true,
2546 no_icons: false,
2547 no_gradient: true,
2548 a11y: false,
2549 });
2550
2551 assert!(matches!(context.resolved.primary, Color::Mono(_)));
2552 assert!(matches!(context.resolved.background, Color::Mono(_)));
2553 assert!(matches!(context.resolved.text, Color::Mono(_)));
2554 }
2555
2556 #[test]
2557 fn accessibility_role_markers_prioritize_text_labels() {
2558 let markers = RoleMarkers::from_options(StyleOptions {
2559 preset: UiThemePreset::TokyoNight,
2560 dark_mode: true,
2561 color_profile: ColorProfile::Ansi256,
2562 no_color: false,
2563 no_icons: true,
2564 no_gradient: true,
2565 a11y: true,
2566 });
2567
2568 assert_eq!(markers.user, "[user]");
2569 assert_eq!(markers.assistant, "[assistant]");
2570 assert_eq!(markers.tool, "[tool]");
2571 assert_eq!(markers.system, "[system]");
2572 }
2573
2574 #[test]
2575 fn base_contrast_is_wcag_aa_or_higher_for_all_presets() {
2576 for preset in UiThemePreset::all() {
2577 let dark_mode = !matches!(
2578 preset,
2579 UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2580 );
2581 let context = StyleContext::from_options(StyleOptions {
2582 preset,
2583 dark_mode,
2584 color_profile: ColorProfile::TrueColor,
2585 no_color: false,
2586 no_icons: false,
2587 no_gradient: false,
2588 a11y: false,
2589 });
2590
2591 let root = context.style(STYLE_APP_ROOT);
2592 let fg = root.fg.expect("app.root must define foreground");
2593 let bg = root.bg.expect("app.root must define background");
2594 let ratio = ftui::style::contrast_ratio_packed(fg, bg);
2595 assert!(
2596 ratio >= 3.5,
2597 "contrast too low for {}: {ratio}",
2598 preset.name()
2599 );
2600 }
2601 }
2602
2603 #[test]
2604 fn high_contrast_preset_keeps_selection_legible() {
2605 let context = StyleContext::from_options(StyleOptions {
2606 preset: UiThemePreset::HighContrast,
2607 dark_mode: true,
2608 color_profile: ColorProfile::Ansi16,
2609 no_color: false,
2610 no_icons: false,
2611 no_gradient: true,
2612 a11y: true,
2613 });
2614
2615 let selected = context.style(STYLE_RESULT_ROW_SELECTED);
2616 let fg = selected
2617 .fg
2618 .expect("selected row style should define foreground color");
2619 let bg = selected
2620 .bg
2621 .expect("selected row style should define background color");
2622
2623 let ratio = ftui::style::contrast_ratio_packed(fg, bg);
2624 assert!(ratio >= 4.5, "selected row contrast too low: {ratio}");
2625 }
2626
2627 #[test]
2628 fn theme_config_roundtrip_preserves_fields() {
2629 let config = ThemeConfig {
2630 version: THEME_CONFIG_VERSION,
2631 base_preset: Some(UiThemePreset::Nord),
2632 };
2633
2634 let json = config
2635 .to_json_pretty()
2636 .expect("theme config should serialize");
2637 let parsed = ThemeConfig::from_json_str(&json).expect("theme config should deserialize");
2638 assert_eq!(parsed, config);
2639 }
2640
2641 #[test]
2642 fn theme_config_json_snapshot_is_stable() {
2643 let config = ThemeConfig {
2644 version: THEME_CONFIG_VERSION,
2645 base_preset: Some(UiThemePreset::Catppuccin),
2646 };
2647
2648 let json = config.to_json_pretty().expect("config should serialize");
2649 let expected = r##"{
2650 "version": 1,
2651 "base_preset": "catppuccin"
2652}"##;
2653 assert_eq!(json, expected);
2654 }
2655
2656 #[test]
2657 fn theme_config_allows_known_preset_aliases() {
2658 let config_json = r#"{
2660 "version": 1,
2661 "base_preset": "high_contrast",
2662 "colors": {}
2663}"#;
2664
2665 let parsed =
2666 ThemeConfig::from_json_str(config_json).expect("preset alias should deserialize");
2667 assert_eq!(parsed.base_preset, Some(UiThemePreset::HighContrast));
2668 }
2669
2670 #[test]
2671 fn theme_config_backwards_compat_dark_alias() {
2672 let config_json = r#"{"version":1,"base_preset":"dark"}"#;
2673 let parsed = ThemeConfig::from_json_str(config_json).expect("dark alias should work");
2674 assert_eq!(parsed.base_preset, Some(UiThemePreset::TokyoNight));
2675 }
2676
2677 #[test]
2678 fn theme_config_backwards_compat_light_alias() {
2679 let config_json = r#"{"version":1,"base_preset":"light"}"#;
2680 let parsed = ThemeConfig::from_json_str(config_json).expect("light alias should work");
2681 assert_eq!(parsed.base_preset, Some(UiThemePreset::Daylight));
2682 }
2683
2684 #[test]
2685 fn preset_downgrades_to_ansi16_profile() {
2686 let config = ThemeConfig {
2687 version: THEME_CONFIG_VERSION,
2688 base_preset: Some(UiThemePreset::TokyoNight),
2689 };
2690
2691 let context = StyleContext::from_options_with_theme_config(
2692 StyleOptions {
2693 preset: UiThemePreset::Daylight,
2694 dark_mode: false,
2695 color_profile: ColorProfile::Ansi16,
2696 no_color: false,
2697 no_icons: false,
2698 no_gradient: false,
2699 a11y: false,
2700 },
2701 &config,
2702 );
2703
2704 assert!(matches!(context.resolved.text, Color::Ansi16(_)));
2705 assert!(matches!(context.resolved.background, Color::Ansi16(_)));
2706 }
2707
2708 #[test]
2709 fn theme_config_file_roundtrip_works() {
2710 let now = SystemTime::now()
2711 .duration_since(UNIX_EPOCH)
2712 .expect("clock should be valid")
2713 .as_nanos();
2714 let path = std::env::temp_dir().join(format!("cass-theme-config-{now}.json"));
2715
2716 let config = ThemeConfig {
2717 version: THEME_CONFIG_VERSION,
2718 base_preset: Some(UiThemePreset::Dracula),
2719 };
2720
2721 config
2722 .save_to_path(&path)
2723 .expect("theme config should save to disk");
2724 let loaded = ThemeConfig::load_from_path(&path).expect("theme config should reload");
2725 assert_eq!(loaded, config);
2726
2727 let _ = fs::remove_file(path);
2728 }
2729
2730 #[test]
2731 fn base_preset_switches_dark_mode() {
2732 let config = ThemeConfig {
2733 version: THEME_CONFIG_VERSION,
2734 base_preset: Some(UiThemePreset::Daylight),
2735 };
2736 let ctx = StyleContext::from_options_with_theme_config(
2737 StyleOptions::default(), &config,
2739 );
2740
2741 assert_eq!(ctx.options.preset, UiThemePreset::Daylight);
2742 assert!(!ctx.options.dark_mode, "Daylight preset → dark_mode=false");
2743 }
2744
2745 #[test]
2746 fn empty_config_does_not_change_theme() {
2747 let config = ThemeConfig {
2748 version: THEME_CONFIG_VERSION,
2749 base_preset: None,
2750 };
2751 let base_ctx = StyleContext::from_options(StyleOptions::default());
2752 let overridden_ctx =
2753 StyleContext::from_options_with_theme_config(StyleOptions::default(), &config);
2754
2755 assert_eq!(base_ctx.resolved.text, overridden_ctx.resolved.text);
2757 assert_eq!(
2758 base_ctx.resolved.background,
2759 overridden_ctx.resolved.background
2760 );
2761 }
2762
2763 #[test]
2764 fn config_base_preset_overrides_options_preset() {
2765 let config = ThemeConfig {
2767 version: THEME_CONFIG_VERSION,
2768 base_preset: Some(UiThemePreset::Nord),
2769 };
2770 let ctx = StyleContext::from_options_with_theme_config(
2771 StyleOptions {
2772 preset: UiThemePreset::TokyoNight,
2773 ..StyleOptions::default()
2774 },
2775 &config,
2776 );
2777
2778 assert_eq!(
2779 ctx.options.preset,
2780 UiThemePreset::Nord,
2781 "config.base_preset should override options.preset"
2782 );
2783 }
2784
2785 const CRITICAL_FG_TOKENS: &[&str] = &[
2787 STYLE_TEXT_PRIMARY,
2788 STYLE_TEXT_MUTED,
2789 STYLE_TEXT_SUBTLE,
2790 STYLE_STATUS_SUCCESS,
2791 STYLE_STATUS_WARNING,
2792 STYLE_STATUS_ERROR,
2793 STYLE_STATUS_INFO,
2794 STYLE_ROLE_USER,
2795 STYLE_ROLE_ASSISTANT,
2796 STYLE_ROLE_TOOL,
2797 STYLE_ROLE_SYSTEM,
2798 STYLE_SCORE_HIGH,
2799 STYLE_SCORE_MID,
2800 STYLE_SCORE_LOW,
2801 STYLE_KBD_KEY,
2802 STYLE_KBD_DESC,
2803 ];
2804
2805 const CRITICAL_BG_TOKENS: &[&str] = &[
2807 STYLE_APP_ROOT,
2808 STYLE_PILL_ACTIVE,
2809 STYLE_PILL_INACTIVE,
2810 STYLE_TAB_ACTIVE,
2811 STYLE_RESULT_ROW_SELECTED,
2812 ];
2813
2814 #[test]
2815 fn critical_fg_tokens_always_have_foreground() {
2816 for preset in UiThemePreset::all() {
2817 for &profile in &[
2818 ColorProfile::TrueColor,
2819 ColorProfile::Ansi256,
2820 ColorProfile::Ansi16,
2821 ] {
2822 let dark_mode = !matches!(
2823 preset,
2824 UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2825 );
2826 let ctx = StyleContext::from_options(StyleOptions {
2827 preset,
2828 dark_mode,
2829 color_profile: profile,
2830 no_color: false,
2831 no_icons: false,
2832 no_gradient: false,
2833 a11y: false,
2834 });
2835
2836 for &token in CRITICAL_FG_TOKENS {
2837 let style = ctx.style(token);
2838 assert!(
2839 style.fg.is_some(),
2840 "Token {token} must have fg for preset {} profile {:?}",
2841 preset.name(),
2842 profile
2843 );
2844 }
2845 }
2846 }
2847 }
2848
2849 #[test]
2850 fn critical_bg_tokens_always_have_background() {
2851 for preset in UiThemePreset::all() {
2852 for &profile in &[
2853 ColorProfile::TrueColor,
2854 ColorProfile::Ansi256,
2855 ColorProfile::Ansi16,
2856 ] {
2857 let dark_mode = !matches!(
2858 preset,
2859 UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2860 );
2861 let ctx = StyleContext::from_options(StyleOptions {
2862 preset,
2863 dark_mode,
2864 color_profile: profile,
2865 no_color: false,
2866 no_icons: false,
2867 no_gradient: false,
2868 a11y: false,
2869 });
2870
2871 for &token in CRITICAL_BG_TOKENS {
2872 let style = ctx.style(token);
2873 assert!(
2874 style.bg.is_some(),
2875 "Token {token} must have bg for preset {} profile {:?}",
2876 preset.name(),
2877 profile
2878 );
2879 }
2880 }
2881 }
2882 }
2883
2884 #[test]
2885 fn a11y_mode_adds_emphasis_to_roles() {
2886 for preset in UiThemePreset::all() {
2888 let dark_mode = !matches!(
2889 preset,
2890 UiThemePreset::Daylight | UiThemePreset::SolarizedLight
2891 );
2892 let ctx = StyleContext::from_options(StyleOptions {
2893 preset,
2894 dark_mode,
2895 color_profile: ColorProfile::TrueColor,
2896 no_color: false,
2897 no_icons: false,
2898 no_gradient: true,
2899 a11y: true,
2900 });
2901
2902 let user = ctx.style(STYLE_ROLE_USER);
2903 let assistant = ctx.style(STYLE_ROLE_ASSISTANT);
2904 assert!(
2906 user.fg.is_some(),
2907 "ROLE_USER must have fg in a11y mode for {}",
2908 preset.name()
2909 );
2910 assert!(
2911 assistant.fg.is_some(),
2912 "ROLE_ASSISTANT must have fg in a11y mode for {}",
2913 preset.name()
2914 );
2915 }
2916 }
2917
2918 #[test]
2919 fn gutter_tokens_derive_from_role_tokens() {
2920 for preset in UiThemePreset::all() {
2923 let ctx = context_for_preset(preset);
2924 let role_user = ctx.style(STYLE_ROLE_USER);
2925 let gutter_user = ctx.style(STYLE_ROLE_GUTTER_USER);
2926
2927 assert_eq!(
2929 role_user.fg,
2930 gutter_user.fg,
2931 "GUTTER_USER.fg should match ROLE_USER.fg for preset {}",
2932 preset.name()
2933 );
2934 assert!(
2936 gutter_user.bg.is_some(),
2937 "GUTTER_USER must have bg for preset {}",
2938 preset.name()
2939 );
2940 }
2941 }
2942
2943 #[test]
2946 fn deco_full_wide_fancy_uses_rounded() {
2947 use crate::ui::app::LayoutBreakpoint as LB;
2948 use ftui::render::budget::DegradationLevel as DL;
2949
2950 let policy = DecorativePolicy::resolve(StyleOptions::default(), DL::Full, LB::Wide, true);
2951 assert_eq!(policy.border_tier, BorderTier::Rounded);
2952 assert!(policy.show_icons);
2953 assert!(policy.use_styling);
2954 assert!(policy.render_content);
2955 }
2956
2957 #[test]
2958 fn deco_full_narrow_downgrades_to_square() {
2959 use crate::ui::app::LayoutBreakpoint as LB;
2960 use ftui::render::budget::DegradationLevel as DL;
2961
2962 let policy = DecorativePolicy::resolve(StyleOptions::default(), DL::Full, LB::Narrow, true);
2963 assert_eq!(
2964 policy.border_tier,
2965 BorderTier::Square,
2966 "Narrow breakpoint should force Square even with fancy_borders=true"
2967 );
2968 assert!(policy.show_icons);
2969 }
2970
2971 #[test]
2972 fn deco_fancy_off_uses_square() {
2973 use crate::ui::app::LayoutBreakpoint as LB;
2974 use ftui::render::budget::DegradationLevel as DL;
2975
2976 let policy = DecorativePolicy::resolve(StyleOptions::default(), DL::Full, LB::Wide, false);
2977 assert_eq!(policy.border_tier, BorderTier::Square);
2978 }
2979
2980 #[test]
2981 fn deco_simple_borders_forces_square() {
2982 use crate::ui::app::LayoutBreakpoint as LB;
2983 use ftui::render::budget::DegradationLevel as DL;
2984
2985 let policy =
2986 DecorativePolicy::resolve(StyleOptions::default(), DL::SimpleBorders, LB::Wide, true);
2987 assert_eq!(policy.border_tier, BorderTier::Square);
2988 assert!(
2989 policy.use_styling,
2990 "SimpleBorders should still allow styling"
2991 );
2992 }
2993
2994 #[test]
2995 fn deco_no_styling_drops_color() {
2996 use crate::ui::app::LayoutBreakpoint as LB;
2997 use ftui::render::budget::DegradationLevel as DL;
2998
2999 let policy =
3000 DecorativePolicy::resolve(StyleOptions::default(), DL::NoStyling, LB::Wide, true);
3001 assert_eq!(policy.border_tier, BorderTier::Square);
3002 assert!(!policy.use_styling, "NoStyling should drop color");
3003 assert!(policy.show_icons, "NoStyling should still show icons");
3004 }
3005
3006 #[test]
3007 fn deco_essential_only_strips_everything() {
3008 use crate::ui::app::LayoutBreakpoint as LB;
3009 use ftui::render::budget::DegradationLevel as DL;
3010
3011 let policy =
3012 DecorativePolicy::resolve(StyleOptions::default(), DL::EssentialOnly, LB::Wide, true);
3013 assert_eq!(policy.border_tier, BorderTier::None);
3014 assert!(!policy.show_icons);
3015 assert!(!policy.use_styling);
3016 assert!(
3017 policy.render_content,
3018 "EssentialOnly should still render content"
3019 );
3020 }
3021
3022 #[test]
3023 fn deco_skeleton_drops_content() {
3024 use crate::ui::app::LayoutBreakpoint as LB;
3025 use ftui::render::budget::DegradationLevel as DL;
3026
3027 let policy =
3028 DecorativePolicy::resolve(StyleOptions::default(), DL::Skeleton, LB::Wide, true);
3029 assert!(!policy.render_content, "Skeleton should not render content");
3030 }
3031
3032 #[test]
3033 fn deco_no_color_drops_styling() {
3034 use crate::ui::app::LayoutBreakpoint as LB;
3035 use ftui::render::budget::DegradationLevel as DL;
3036
3037 let opts = StyleOptions {
3038 no_color: true,
3039 color_profile: ColorProfile::Mono,
3040 no_gradient: true,
3041 ..StyleOptions::default()
3042 };
3043 let policy = DecorativePolicy::resolve(opts, DL::Full, LB::Wide, true);
3044 assert!(!policy.use_styling, "NO_COLOR should drop styling");
3045 assert!(!policy.use_gradients, "NO_COLOR should drop gradients");
3046 }
3047
3048 #[test]
3049 fn deco_no_icons_suppresses_icons() {
3050 use crate::ui::app::LayoutBreakpoint as LB;
3051 use ftui::render::budget::DegradationLevel as DL;
3052
3053 let opts = StyleOptions {
3054 no_icons: true,
3055 ..StyleOptions::default()
3056 };
3057 let policy = DecorativePolicy::resolve(opts, DL::Full, LB::Wide, true);
3058 assert!(!policy.show_icons, "CASS_NO_ICONS should suppress icons");
3059 }
3060
3061 #[test]
3062 fn deco_monotonic_degradation() {
3063 use crate::ui::app::LayoutBreakpoint as LB;
3064 use ftui::render::budget::DegradationLevel as DL;
3065
3066 let levels = [
3067 DL::Full,
3068 DL::SimpleBorders,
3069 DL::NoStyling,
3070 DL::EssentialOnly,
3071 DL::Skeleton,
3072 DL::SkipFrame,
3073 ];
3074 let opts = StyleOptions::default();
3075 let mut prev: Option<DecorativePolicy> = None;
3076
3077 for &level in &levels {
3078 let policy = DecorativePolicy::resolve(opts, level, LB::Wide, true);
3079
3080 if let Some(p) = prev {
3081 assert!(
3083 policy.border_tier >= p.border_tier,
3084 "Border tier should degrade monotonically: {:?} at {:?}",
3085 policy.border_tier,
3086 level
3087 );
3088 if !p.show_icons {
3090 assert!(
3091 !policy.show_icons,
3092 "show_icons should not re-enable at {:?}",
3093 level
3094 );
3095 }
3096 if !p.use_styling {
3097 assert!(
3098 !policy.use_styling,
3099 "use_styling should not re-enable at {:?}",
3100 level
3101 );
3102 }
3103 if !p.render_content {
3104 assert!(
3105 !policy.render_content,
3106 "render_content should not re-enable at {:?}",
3107 level
3108 );
3109 }
3110 }
3111 prev = Some(policy);
3112 }
3113 }
3114
3115 #[test]
3118 fn pane_title_focused_has_bold_accent() {
3119 for preset in UiThemePreset::all() {
3120 let ctx = context_for_preset(preset);
3121 let focused = ctx.style(STYLE_PANE_TITLE_FOCUSED);
3122 assert!(
3123 focused.fg.is_some(),
3124 "{preset:?}: focused title should have fg"
3125 );
3126 assert!(
3127 focused
3128 .attrs
3129 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3130 "{preset:?}: focused title should be bold"
3131 );
3132 }
3133 }
3134
3135 #[test]
3136 fn pane_title_unfocused_is_muted_not_bold() {
3137 for preset in UiThemePreset::all() {
3138 let ctx = context_for_preset(preset);
3139 let unfocused = ctx.style(STYLE_PANE_TITLE_UNFOCUSED);
3140 assert!(
3141 unfocused.fg.is_some(),
3142 "{preset:?}: unfocused title should have fg"
3143 );
3144 assert!(
3145 !unfocused
3146 .attrs
3147 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3148 "{preset:?}: unfocused title should NOT be bold"
3149 );
3150 }
3151 }
3152
3153 #[test]
3154 fn pane_title_focused_differs_from_unfocused() {
3155 for preset in UiThemePreset::all() {
3156 let ctx = context_for_preset(preset);
3157 let focused = ctx.style(STYLE_PANE_TITLE_FOCUSED);
3158 let unfocused = ctx.style(STYLE_PANE_TITLE_UNFOCUSED);
3159 assert_ne!(
3160 focused.fg, unfocused.fg,
3161 "{preset:?}: focused and unfocused title fg should differ"
3162 );
3163 }
3164 }
3165
3166 #[test]
3167 fn split_handle_has_fg_and_bg() {
3168 for preset in UiThemePreset::all() {
3169 let ctx = context_for_preset(preset);
3170 let handle = ctx.style(STYLE_SPLIT_HANDLE);
3171 assert!(
3172 handle.fg.is_some(),
3173 "{preset:?}: split handle should have fg"
3174 );
3175 assert!(
3176 handle.bg.is_some(),
3177 "{preset:?}: split handle should have bg"
3178 );
3179 }
3180 }
3181
3182 #[test]
3183 fn split_handle_fg_differs_from_own_bg() {
3184 for preset in UiThemePreset::all() {
3185 let ctx = context_for_preset(preset);
3186 let handle = ctx.style(STYLE_SPLIT_HANDLE);
3187 assert_ne!(
3189 handle.fg, handle.bg,
3190 "{preset:?}: split handle fg should differ from its bg"
3191 );
3192 }
3193 }
3194
3195 #[test]
3198 fn source_local_differs_from_source_remote() {
3199 for preset in UiThemePreset::all() {
3200 let ctx = context_for_preset(preset);
3201 let local = ctx.style(STYLE_SOURCE_LOCAL);
3202 let remote = ctx.style(STYLE_SOURCE_REMOTE);
3203 assert_ne!(
3204 local.fg, remote.fg,
3205 "{preset:?}: local and remote source fg should differ"
3206 );
3207 }
3208 }
3209
3210 #[test]
3211 fn source_remote_is_italic() {
3212 for preset in UiThemePreset::all() {
3213 let ctx = context_for_preset(preset);
3214 let remote = ctx.style(STYLE_SOURCE_REMOTE);
3215 assert!(
3216 remote
3217 .attrs
3218 .is_some_and(|a| a.contains(ftui::StyleFlags::ITALIC)),
3219 "{preset:?}: remote source should be italic"
3220 );
3221 }
3222 }
3223
3224 #[test]
3225 fn location_style_has_fg() {
3226 for preset in UiThemePreset::all() {
3227 let ctx = context_for_preset(preset);
3228 let loc = ctx.style(STYLE_LOCATION);
3229 assert!(
3230 loc.fg.is_some(),
3231 "{preset:?}: location style should have fg"
3232 );
3233 }
3234 }
3235
3236 #[test]
3237 fn result_scanning_hierarchy_is_ordered() {
3238 for preset in UiThemePreset::all() {
3241 let ctx = context_for_preset(preset);
3242 let score_high = ctx.style(STYLE_SCORE_HIGH);
3243 let source_local = ctx.style(STYLE_SOURCE_LOCAL);
3244 let location = ctx.style(STYLE_LOCATION);
3245
3246 assert!(
3248 score_high
3249 .attrs
3250 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3251 "{preset:?}: score high should be bold"
3252 );
3253 assert!(
3255 !source_local
3256 .attrs
3257 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3258 "{preset:?}: source local should not be bold"
3259 );
3260 assert!(
3261 !location
3262 .attrs
3263 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD)),
3264 "{preset:?}: location should not be bold"
3265 );
3266 }
3267 }
3268
3269 #[test]
3270 fn capability_matrix_profiles_resolve_expected_color_profiles() {
3271 use crate::ui::app::LayoutBreakpoint as LB;
3272 use ftui::core::terminal_capabilities::TerminalProfile;
3273 use ftui::render::budget::DegradationLevel as DL;
3274
3275 let fixtures = [
3276 (TerminalProfile::Xterm256Color, "xterm-256color"),
3277 (TerminalProfile::Screen, "screen"),
3278 (TerminalProfile::Dumb, "dumb"),
3279 (TerminalProfile::WindowsConsole, "windows-console"),
3280 (TerminalProfile::Kitty, "kitty"),
3281 ];
3282
3283 for (profile, term) in fixtures {
3284 let caps = TerminalCapabilities::from_profile(profile);
3285 let diag = style_policy_diagnostic(
3286 caps,
3287 CapabilityMatrixInputs {
3288 term: Some(term),
3289 ..CapabilityMatrixInputs::default()
3290 },
3291 DL::Full,
3292 LB::Wide,
3293 true,
3294 );
3295
3296 let expected_profile = if caps.true_color {
3297 "truecolor"
3298 } else if caps.colors_256 {
3299 "ansi256"
3300 } else {
3301 "ansi16"
3302 };
3303
3304 assert_eq!(
3305 diag.terminal_profile,
3306 profile.as_str(),
3307 "terminal profile id should be preserved in diagnostics"
3308 );
3309 assert_eq!(
3310 diag.resolved_color_profile, expected_profile,
3311 "profile {profile} should map to expected color profile"
3312 );
3313 assert_eq!(diag.term.as_deref(), Some(term));
3314 assert_eq!(
3315 diag.capability_unicode_box_drawing, caps.unicode_box_drawing,
3316 "unicode capability should be reported verbatim for {profile}"
3317 );
3318 }
3319 }
3320
3321 #[test]
3322 fn capability_matrix_no_color_precedence_matches_policy_contract() {
3323 use crate::ui::app::LayoutBreakpoint as LB;
3324 use ftui::core::terminal_capabilities::TerminalProfile;
3325 use ftui::render::budget::DegradationLevel as DL;
3326
3327 let caps = TerminalCapabilities::from_profile(TerminalProfile::Kitty);
3328
3329 let no_color_only = style_policy_diagnostic(
3330 caps,
3331 CapabilityMatrixInputs {
3332 term: Some("xterm-kitty"),
3333 no_color: true,
3334 cass_respect_no_color: false,
3335 ..CapabilityMatrixInputs::default()
3336 },
3337 DL::Full,
3338 LB::Wide,
3339 true,
3340 );
3341 assert!(
3342 !no_color_only.resolved_no_color,
3343 "NO_COLOR alone must not force monochrome"
3344 );
3345 assert_ne!(
3346 no_color_only.resolved_color_profile, "mono",
3347 "NO_COLOR alone should keep color enabled"
3348 );
3349
3350 let respect_no_color = style_policy_diagnostic(
3351 caps,
3352 CapabilityMatrixInputs {
3353 term: Some("xterm-kitty"),
3354 no_color: true,
3355 cass_respect_no_color: true,
3356 ..CapabilityMatrixInputs::default()
3357 },
3358 DL::Full,
3359 LB::Wide,
3360 true,
3361 );
3362 assert!(respect_no_color.resolved_no_color);
3363 assert_eq!(respect_no_color.resolved_color_profile, "mono");
3364 assert!(
3365 !respect_no_color.policy_use_styling,
3366 "monochrome mode should disable styling"
3367 );
3368 assert!(
3369 !respect_no_color.policy_use_gradients,
3370 "monochrome mode should disable gradients"
3371 );
3372
3373 let cass_no_color = style_policy_diagnostic(
3374 caps,
3375 CapabilityMatrixInputs {
3376 term: Some("xterm-kitty"),
3377 cass_no_color: true,
3378 cass_color_profile: Some("truecolor"),
3379 ..CapabilityMatrixInputs::default()
3380 },
3381 DL::Full,
3382 LB::Wide,
3383 true,
3384 );
3385 assert!(cass_no_color.resolved_no_color);
3386 assert_eq!(
3387 cass_no_color.resolved_color_profile, "mono",
3388 "CASS_NO_COLOR must override explicit profile requests"
3389 );
3390 }
3391
3392 #[test]
3393 fn capability_matrix_diagnostic_payload_is_machine_readable_json() {
3394 use crate::ui::app::LayoutBreakpoint as LB;
3395 use ftui::core::terminal_capabilities::TerminalProfile;
3396 use ftui::render::budget::DegradationLevel as DL;
3397
3398 let caps = TerminalCapabilities::from_profile(TerminalProfile::Xterm256Color);
3399 let diag = style_policy_diagnostic(
3400 caps,
3401 CapabilityMatrixInputs {
3402 term: Some("xterm-256color"),
3403 colorterm: Some("truecolor"),
3404 ..CapabilityMatrixInputs::default()
3405 },
3406 DL::SimpleBorders,
3407 LB::Medium,
3408 true,
3409 );
3410
3411 let json = match serde_json::to_value(&diag) {
3412 Ok(value) => value,
3413 Err(error) => panic!("diagnostic payload should serialize: {error}"),
3414 };
3415 let object = match json.as_object() {
3416 Some(map) => map,
3417 None => panic!("diagnostic payload must serialize to a JSON object"),
3418 };
3419
3420 for required in [
3421 "terminal_profile",
3422 "degradation",
3423 "breakpoint",
3424 "resolved_color_profile",
3425 "policy_border_tier",
3426 "policy_use_styling",
3427 "policy_use_gradients",
3428 "policy_render_content",
3429 "capability_unicode_box_drawing",
3430 "env_no_color",
3431 "env_cass_respect_no_color",
3432 "env_cass_no_color",
3433 ] {
3434 assert!(
3435 object.contains_key(required),
3436 "diagnostic payload missing required key: {required}"
3437 );
3438 }
3439 }
3440
3441 #[test]
3442 fn capability_matrix_degradation_transitions_are_monotonic() {
3443 use crate::ui::app::LayoutBreakpoint as LB;
3444 use ftui::core::terminal_capabilities::TerminalProfile;
3445 use ftui::render::budget::DegradationLevel as DL;
3446
3447 fn border_rank(tier: &str) -> u8 {
3448 match tier {
3449 "rounded" => 0,
3450 "square" => 1,
3451 "none" => 2,
3452 other => panic!("unexpected border tier: {other}"),
3453 }
3454 }
3455
3456 let caps = TerminalCapabilities::from_profile(TerminalProfile::Kitty);
3457 let levels = [
3458 DL::Full,
3459 DL::SimpleBorders,
3460 DL::NoStyling,
3461 DL::EssentialOnly,
3462 ];
3463 let mut prev: Option<StylePolicyDiagnostic> = None;
3464
3465 for level in levels {
3466 let diag = style_policy_diagnostic(
3467 caps,
3468 CapabilityMatrixInputs {
3469 term: Some("xterm-kitty"),
3470 ..CapabilityMatrixInputs::default()
3471 },
3472 level,
3473 LB::Wide,
3474 true,
3475 );
3476
3477 if let Some(last) = &prev {
3478 assert!(
3479 border_rank(diag.policy_border_tier) >= border_rank(last.policy_border_tier),
3480 "border tier should only weaken across degradation levels"
3481 );
3482 if !last.policy_show_icons {
3483 assert!(!diag.policy_show_icons, "icons must not re-enable");
3484 }
3485 if !last.policy_use_styling {
3486 assert!(
3487 !diag.policy_use_styling,
3488 "styling must not re-enable after being stripped"
3489 );
3490 }
3491 if !last.policy_use_gradients {
3492 assert!(
3493 !diag.policy_use_gradients,
3494 "gradients must not re-enable after being stripped"
3495 );
3496 }
3497 if !last.policy_render_content {
3498 assert!(
3499 !diag.policy_render_content,
3500 "content rendering must not re-enable after being stripped"
3501 );
3502 }
3503 }
3504 prev = Some(diag);
3505 }
3506 }
3507
3508 #[test]
3511 fn agent_accent_style_is_bold_for_all_agents() {
3512 let ctx = context_for_preset(UiThemePreset::TokyoNight);
3513 let agents = [
3514 "claude_code",
3515 "codex",
3516 "cline",
3517 "gemini",
3518 "amp",
3519 "aider",
3520 "cursor",
3521 "chatgpt",
3522 "opencode",
3523 "pi_agent",
3524 "unknown_agent",
3525 ];
3526 for agent in agents {
3527 let style = ctx.agent_accent_style(agent);
3528 assert!(
3529 style.fg.is_some(),
3530 "agent_accent_style({agent}) must have fg"
3531 );
3532 assert!(
3533 style.has_attr(ftui::StyleFlags::BOLD),
3534 "agent_accent_style({agent}) must be bold"
3535 );
3536 }
3537 }
3538
3539 #[test]
3540 fn agent_accent_style_adds_badge_bg_in_color_modes() {
3541 for preset in UiThemePreset::all() {
3542 let ctx = context_for_preset(preset);
3543 let style = ctx.agent_accent_style("codex");
3544 let fg = style.fg.expect("agent accent style should define fg");
3545 let bg = style.bg.expect("agent accent style should define bg tint");
3546 assert!(
3547 style.has_attr(ftui::StyleFlags::BOLD),
3548 "agent accent style should remain bold for preset {}",
3549 preset.name()
3550 );
3551 assert_ne!(
3552 Some(bg),
3553 Some(to_packed(ctx.resolved.surface)),
3554 "badge bg should differ from plain surface background for preset {}",
3555 preset.name()
3556 );
3557 let ratio = ftui::style::contrast_ratio_packed(fg, bg);
3558 assert!(
3559 ratio >= 3.0,
3560 "agent accent badge contrast too low ({ratio:.2}) for preset {}",
3561 preset.name()
3562 );
3563 }
3564 }
3565
3566 #[test]
3567 fn agent_accent_style_uses_fg_only_in_no_color_and_a11y_modes() {
3568 let no_color_ctx = StyleContext::from_options(StyleOptions {
3569 preset: UiThemePreset::TokyoNight,
3570 dark_mode: true,
3571 color_profile: ColorProfile::Mono,
3572 no_color: true,
3573 no_icons: false,
3574 no_gradient: true,
3575 a11y: false,
3576 });
3577 let no_color_style = no_color_ctx.agent_accent_style("codex");
3578 assert!(
3579 no_color_style.bg.is_none(),
3580 "no-color mode should avoid accent background tint"
3581 );
3582
3583 let a11y_ctx = StyleContext::from_options(StyleOptions {
3584 preset: UiThemePreset::TokyoNight,
3585 dark_mode: true,
3586 color_profile: ColorProfile::TrueColor,
3587 no_color: false,
3588 no_icons: false,
3589 no_gradient: true,
3590 a11y: true,
3591 });
3592 let a11y_style = a11y_ctx.agent_accent_style("codex");
3593 assert!(
3594 a11y_style.bg.is_none(),
3595 "a11y mode should avoid accent background tint"
3596 );
3597 }
3598
3599 #[test]
3600 fn result_row_style_for_agent_tints_background_when_color_enabled() {
3601 let ctx = context_for_preset(UiThemePreset::TokyoNight);
3602 let base = ctx.style(STYLE_RESULT_ROW);
3603 let tinted = ctx.result_row_style_for_agent(base, "codex");
3604 assert!(base.bg.is_some(), "base row style should have a background");
3605 assert!(
3606 tinted.bg.is_some(),
3607 "tinted row style should retain a background"
3608 );
3609 assert_eq!(
3610 tinted.fg, base.fg,
3611 "row tinting should preserve existing foreground color"
3612 );
3613 assert_ne!(
3614 tinted.bg, base.bg,
3615 "row tinting should shift background toward agent accent"
3616 );
3617 }
3618
3619 #[test]
3620 fn result_row_style_for_agent_preserves_base_style_in_no_color_or_a11y() {
3621 let base = Style::new()
3622 .fg(to_packed(Color::rgb(230, 230, 230)))
3623 .bg(to_packed(Color::rgb(32, 36, 48)));
3624
3625 let no_color_ctx = StyleContext::from_options(StyleOptions {
3626 preset: UiThemePreset::TokyoNight,
3627 dark_mode: true,
3628 color_profile: ColorProfile::Mono,
3629 no_color: true,
3630 no_icons: false,
3631 no_gradient: true,
3632 a11y: false,
3633 });
3634 assert_eq!(
3635 no_color_ctx.result_row_style_for_agent(base, "codex"),
3636 base,
3637 "no-color mode should not tint row backgrounds"
3638 );
3639
3640 let a11y_ctx = StyleContext::from_options(StyleOptions {
3641 preset: UiThemePreset::TokyoNight,
3642 dark_mode: true,
3643 color_profile: ColorProfile::TrueColor,
3644 no_color: false,
3645 no_icons: false,
3646 no_gradient: true,
3647 a11y: true,
3648 });
3649 assert_eq!(
3650 a11y_ctx.result_row_style_for_agent(base, "codex"),
3651 base,
3652 "a11y mode should not tint row backgrounds"
3653 );
3654 }
3655
3656 #[test]
3657 fn result_row_tints_are_pairwise_distinct_for_representative_agents() {
3658 let agents = ["claude_code", "codex", "cursor", "gemini", "aider"];
3659 for preset in UiThemePreset::all() {
3660 let ctx = context_for_preset(preset);
3661 let base = ctx.style(STYLE_RESULT_ROW);
3662 let mut tinted_bgs: Vec<(&str, ftui::PackedRgba)> = Vec::new();
3663
3664 for agent in agents {
3665 let tinted = ctx.result_row_style_for_agent(base, agent);
3666 let tinted_bg = tinted
3667 .bg
3668 .expect("color mode result-row tint should define background");
3669 tinted_bgs.push((agent, tinted_bg));
3670 }
3671 let unique_count = tinted_bgs
3672 .iter()
3673 .map(|(_, bg)| *bg)
3674 .collect::<std::collections::HashSet<_>>()
3675 .len();
3676 let min_buckets = if matches!(preset, UiThemePreset::HighContrast) {
3677 2
3678 } else {
3679 4
3680 };
3681 assert!(
3682 unique_count >= min_buckets,
3683 "expected at least {min_buckets} distinct tint buckets (got {unique_count}) for preset {}",
3684 preset.name()
3685 );
3686 }
3687 }
3688
3689 #[test]
3690 fn result_row_tints_preserve_text_legibility_threshold() {
3691 let agents = ["claude_code", "codex", "cursor", "gemini", "aider"];
3692 for preset in UiThemePreset::all() {
3693 let ctx = context_for_preset(preset);
3694 for base_token in [STYLE_RESULT_ROW, STYLE_RESULT_ROW_ALT] {
3695 let base = ctx.style(base_token);
3696 for agent in agents {
3697 let tinted = ctx.result_row_style_for_agent(base, agent);
3698 let fg = tinted
3699 .fg
3700 .expect("result-row style should always define foreground");
3701 let bg = tinted
3702 .bg
3703 .expect("result-row style should always define background");
3704 let ratio = ftui::style::contrast_ratio_packed(fg, bg);
3705 assert!(
3706 ratio >= 2.5,
3707 "text contrast {:.2} below threshold for preset {} token {} agent {}",
3708 ratio,
3709 preset.name(),
3710 base_token,
3711 agent
3712 );
3713 }
3714 }
3715 }
3716 }
3717
3718 #[test]
3719 fn selected_row_affordance_remains_distinct_from_agent_tints() {
3720 let agents = ["claude_code", "codex", "cursor", "gemini", "aider"];
3721 for preset in UiThemePreset::all() {
3722 let ctx = context_for_preset(preset);
3723 let selected = ctx.style(STYLE_RESULT_ROW_SELECTED);
3724 let selected_bg = selected
3725 .bg
3726 .expect("selected-row style should define background");
3727
3728 for base_token in [STYLE_RESULT_ROW, STYLE_RESULT_ROW_ALT] {
3729 let base = ctx.style(base_token);
3730 let base_bg = base.bg.expect("base row style should define background");
3731 let base_separation = ftui::style::contrast_ratio_packed(selected_bg, base_bg);
3732 for agent in agents {
3733 let tinted = ctx.result_row_style_for_agent(base, agent);
3734 let tinted_bg = tinted.bg.expect("result-row tint should define background");
3735 assert_ne!(
3736 selected_bg,
3737 tinted_bg,
3738 "selected background should differ from tinted row background for preset {} token {} agent {}",
3739 preset.name(),
3740 base_token,
3741 agent
3742 );
3743
3744 let separation = ftui::style::contrast_ratio_packed(selected_bg, tinted_bg);
3745 assert!(
3746 separation + 0.03 >= base_separation,
3747 "selected/tinted separation {:.3} regressed too far below base {:.3} for preset {} token {} agent {}",
3748 separation,
3749 base_separation,
3750 preset.name(),
3751 base_token,
3752 agent
3753 );
3754 }
3755 }
3756 }
3757 }
3758
3759 #[test]
3760 fn role_markers_provide_text_disambiguation_in_a11y() {
3761 let markers = RoleMarkers::from_options(StyleOptions {
3762 a11y: true,
3763 ..StyleOptions::default()
3764 });
3765 assert!(
3767 !markers.user.is_empty(),
3768 "a11y user marker must be non-empty"
3769 );
3770 assert!(
3771 !markers.assistant.is_empty(),
3772 "a11y assistant marker must be non-empty"
3773 );
3774 assert_ne!(markers.user, markers.assistant, "user != assistant markers");
3775 assert_ne!(markers.user, markers.tool, "user != tool markers");
3776 assert_ne!(markers.assistant, markers.tool, "assistant != tool markers");
3777 }
3778
3779 #[test]
3780 fn role_markers_empty_when_no_icons() {
3781 let markers = RoleMarkers::from_options(StyleOptions {
3782 no_icons: true,
3783 a11y: false,
3784 ..StyleOptions::default()
3785 });
3786 assert!(
3787 markers.user.is_empty(),
3788 "no_icons should suppress role markers"
3789 );
3790 }
3791
3792 fn context_for_preset(preset: UiThemePreset) -> StyleContext {
3795 let dark_mode = !matches!(
3796 preset,
3797 UiThemePreset::Daylight | UiThemePreset::SolarizedLight
3798 );
3799 StyleContext::from_options(StyleOptions {
3800 preset,
3801 dark_mode,
3802 color_profile: ColorProfile::TrueColor,
3803 no_color: false,
3804 no_icons: false,
3805 no_gradient: false,
3806 a11y: false,
3807 })
3808 }
3809
3810 #[test]
3811 fn pill_active_has_background_for_all_presets() {
3812 for preset in UiThemePreset::all() {
3813 let ctx = context_for_preset(preset);
3814 let style = ctx.style(STYLE_PILL_ACTIVE);
3815 assert!(
3816 style.bg.is_some(),
3817 "STYLE_PILL_ACTIVE must have bg for preset {}",
3818 preset.name()
3819 );
3820 }
3821 }
3822
3823 #[test]
3824 fn tab_active_has_background_for_all_presets() {
3825 for preset in UiThemePreset::all() {
3826 let ctx = context_for_preset(preset);
3827 let style = ctx.style(STYLE_TAB_ACTIVE);
3828 assert!(
3829 style.bg.is_some(),
3830 "STYLE_TAB_ACTIVE must have bg for preset {}",
3831 preset.name()
3832 );
3833 }
3834 }
3835
3836 #[test]
3837 fn tab_inactive_has_no_background() {
3838 for preset in UiThemePreset::all() {
3839 let ctx = context_for_preset(preset);
3840 let style = ctx.style(STYLE_TAB_INACTIVE);
3841 assert!(
3842 style.bg.is_none(),
3843 "STYLE_TAB_INACTIVE should have no bg for preset {}",
3844 preset.name()
3845 );
3846 }
3847 }
3848
3849 #[test]
3850 fn tab_active_differs_from_status_info() {
3851 let ctx = context_for_preset(UiThemePreset::TokyoNight);
3852 let tab = ctx.style(STYLE_TAB_ACTIVE);
3853 let info = ctx.style(STYLE_STATUS_INFO);
3854 assert_ne!(
3855 tab, info,
3856 "STYLE_TAB_ACTIVE must differ from STYLE_STATUS_INFO"
3857 );
3858 }
3859
3860 #[test]
3861 fn pill_active_differs_from_text_primary() {
3862 let ctx = context_for_preset(UiThemePreset::TokyoNight);
3863 let pill = ctx.style(STYLE_PILL_ACTIVE);
3864 let text = ctx.style(STYLE_TEXT_PRIMARY);
3865 assert_ne!(
3866 pill, text,
3867 "STYLE_PILL_ACTIVE must differ from STYLE_TEXT_PRIMARY"
3868 );
3869 }
3870
3871 #[test]
3872 fn tab_and_pill_styles_unique_across_presets() {
3873 let mut tab_styles = std::collections::HashSet::new();
3874 let mut pill_styles = std::collections::HashSet::new();
3875 for preset in UiThemePreset::all() {
3876 let ctx = context_for_preset(preset);
3877 let tab = ctx.style(STYLE_TAB_ACTIVE);
3878 let pill = ctx.style(STYLE_PILL_ACTIVE);
3879 tab_styles.insert(format!("{:?}", tab));
3880 pill_styles.insert(format!("{:?}", pill));
3881 }
3882 assert!(
3883 tab_styles.len() >= 3,
3884 "STYLE_TAB_ACTIVE should produce at least 3 distinct styles across presets, got {}",
3885 tab_styles.len()
3886 );
3887 assert!(
3888 pill_styles.len() >= 3,
3889 "STYLE_PILL_ACTIVE should produce at least 3 distinct styles across presets, got {}",
3890 pill_styles.len()
3891 );
3892 }
3893
3894 #[test]
3897 fn pill_inactive_differs_from_pill_active() {
3898 for preset in UiThemePreset::all() {
3899 let ctx = context_for_preset(preset);
3900 let active = ctx.style(STYLE_PILL_ACTIVE);
3901 let inactive = ctx.style(STYLE_PILL_INACTIVE);
3902 assert_ne!(
3903 active,
3904 inactive,
3905 "STYLE_PILL_INACTIVE must differ from STYLE_PILL_ACTIVE for preset {}",
3906 preset.name()
3907 );
3908 }
3909 }
3910
3911 #[test]
3912 fn pill_inactive_is_not_bold() {
3913 for preset in UiThemePreset::all() {
3914 let ctx = context_for_preset(preset);
3915 let inactive = ctx.style(STYLE_PILL_INACTIVE);
3916 let is_bold = inactive
3917 .attrs
3918 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD));
3919 assert!(
3920 !is_bold,
3921 "STYLE_PILL_INACTIVE should not be bold for preset {}",
3922 preset.name()
3923 );
3924 }
3925 }
3926
3927 #[test]
3928 fn pill_active_is_bold() {
3929 for preset in UiThemePreset::all() {
3930 let ctx = context_for_preset(preset);
3931 let active = ctx.style(STYLE_PILL_ACTIVE);
3932 let is_bold = active
3933 .attrs
3934 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD));
3935 assert!(
3936 is_bold,
3937 "STYLE_PILL_ACTIVE should be bold for preset {}",
3938 preset.name()
3939 );
3940 }
3941 }
3942
3943 #[test]
3944 fn pill_inactive_has_background_for_all_presets() {
3945 for preset in UiThemePreset::all() {
3946 let ctx = context_for_preset(preset);
3947 let inactive = ctx.style(STYLE_PILL_INACTIVE);
3948 assert!(
3949 inactive.bg.is_some(),
3950 "STYLE_PILL_INACTIVE must have bg for preset {}",
3951 preset.name()
3952 );
3953 }
3954 }
3955
3956 #[test]
3957 fn pill_inactive_is_dim() {
3958 for preset in UiThemePreset::all() {
3959 let ctx = context_for_preset(preset);
3960 let inactive = ctx.style(STYLE_PILL_INACTIVE);
3961 let is_dim = inactive
3962 .attrs
3963 .is_some_and(|a| a.contains(ftui::StyleFlags::DIM));
3964 assert!(
3965 is_dim,
3966 "STYLE_PILL_INACTIVE should be dim for preset {}",
3967 preset.name()
3968 );
3969 }
3970 }
3971
3972 #[test]
3973 fn pill_label_has_foreground_and_bold() {
3974 for preset in UiThemePreset::all() {
3975 let ctx = context_for_preset(preset);
3976 let label = ctx.style(STYLE_PILL_LABEL);
3977 assert!(
3978 label.fg.is_some(),
3979 "STYLE_PILL_LABEL must have fg for preset {}",
3980 preset.name()
3981 );
3982 let is_bold = label
3983 .attrs
3984 .is_some_and(|a| a.contains(ftui::StyleFlags::BOLD));
3985 assert!(
3986 is_bold,
3987 "STYLE_PILL_LABEL should be bold for preset {}",
3988 preset.name()
3989 );
3990 }
3991 }
3992
3993 #[test]
3994 fn pill_hierarchy_is_visually_ordered() {
3995 for preset in UiThemePreset::all() {
3997 let ctx = context_for_preset(preset);
3998 let active = ctx.style(STYLE_PILL_ACTIVE);
3999 let inactive = ctx.style(STYLE_PILL_INACTIVE);
4000 let label = ctx.style(STYLE_PILL_LABEL);
4001 assert_ne!(
4003 active.fg,
4004 inactive.fg,
4005 "pill active fg must differ from inactive fg for preset {}",
4006 preset.name()
4007 );
4008 assert_ne!(
4009 active.fg,
4010 label.fg,
4011 "pill active fg must differ from label fg for preset {}",
4012 preset.name()
4013 );
4014 }
4015 }
4016
4017 #[test]
4020 fn crumb_active_differs_from_inactive() {
4021 for preset in UiThemePreset::all() {
4022 let ctx = context_for_preset(preset);
4023 let active = ctx.style(STYLE_CRUMB_ACTIVE);
4024 let inactive = ctx.style(STYLE_CRUMB_INACTIVE);
4025 assert_ne!(
4026 active,
4027 inactive,
4028 "CRUMB_ACTIVE must differ from CRUMB_INACTIVE for preset {}",
4029 preset.name()
4030 );
4031 }
4032 }
4033
4034 #[test]
4035 fn crumb_active_is_bold() {
4036 for preset in UiThemePreset::all() {
4037 let ctx = context_for_preset(preset);
4038 let active = ctx.style(STYLE_CRUMB_ACTIVE);
4039 assert!(
4040 active.has_attr(ftui::StyleFlags::BOLD),
4041 "CRUMB_ACTIVE should be bold for preset {}",
4042 preset.name()
4043 );
4044 }
4045 }
4046
4047 #[test]
4048 fn crumb_separator_has_fg() {
4049 for preset in UiThemePreset::all() {
4050 let ctx = context_for_preset(preset);
4051 let sep = ctx.style(STYLE_CRUMB_SEPARATOR);
4052 assert!(
4053 sep.fg.is_some(),
4054 "CRUMB_SEPARATOR must have fg for preset {}",
4055 preset.name()
4056 );
4057 }
4058 }
4059
4060 #[test]
4061 fn crumb_separator_differs_from_active() {
4062 for preset in UiThemePreset::all() {
4063 let ctx = context_for_preset(preset);
4064 let active = ctx.style(STYLE_CRUMB_ACTIVE);
4065 let sep = ctx.style(STYLE_CRUMB_SEPARATOR);
4066 assert_ne!(
4067 active.fg,
4068 sep.fg,
4069 "CRUMB_SEPARATOR fg must differ from CRUMB_ACTIVE fg for preset {}",
4070 preset.name()
4071 );
4072 }
4073 }
4074
4075 #[test]
4078 fn markdown_theme_h1_uses_primary_color() {
4079 let ctx = context_for_preset(UiThemePreset::TokyoNight);
4080 let md = ctx.markdown_theme();
4081 let expected_fg = to_packed(ctx.resolved.primary);
4082 assert_eq!(
4083 md.h1.fg,
4084 Some(expected_fg),
4085 "h1 fg should match resolved.primary"
4086 );
4087 }
4088
4089 #[test]
4090 fn markdown_theme_code_inline_has_background() {
4091 for preset in UiThemePreset::all() {
4092 let ctx = context_for_preset(preset);
4093 let md = ctx.markdown_theme();
4094 assert!(
4095 md.code_inline.bg.is_some(),
4096 "code_inline must have bg for preset {}",
4097 preset.name()
4098 );
4099 }
4100 }
4101
4102 #[test]
4103 fn markdown_theme_code_block_has_background() {
4104 for preset in UiThemePreset::all() {
4105 let ctx = context_for_preset(preset);
4106 let md = ctx.markdown_theme();
4107 assert!(
4108 md.code_block.bg.is_some(),
4109 "code_block must have bg for preset {}",
4110 preset.name()
4111 );
4112 }
4113 }
4114
4115 #[test]
4116 fn markdown_theme_link_is_underlined() {
4117 let ctx = context_for_preset(UiThemePreset::TokyoNight);
4118 let md = ctx.markdown_theme();
4119 assert!(
4120 md.link.has_attr(ftui::StyleFlags::UNDERLINE),
4121 "link style should include underline"
4122 );
4123 }
4124
4125 #[test]
4126 fn markdown_theme_table_has_themed_border() {
4127 for preset in UiThemePreset::all() {
4128 let ctx = context_for_preset(preset);
4129 let md = ctx.markdown_theme();
4130 assert!(
4131 md.table_theme.border.fg.is_some(),
4132 "table border must have fg for preset {}",
4133 preset.name()
4134 );
4135 assert!(
4136 md.table_theme.header.fg.is_some(),
4137 "table header must have fg for preset {}",
4138 preset.name()
4139 );
4140 }
4141 }
4142
4143 #[test]
4144 fn syntax_highlight_theme_matches_dark_mode() {
4145 let dark_ctx = context_for_preset(UiThemePreset::TokyoNight);
4146 let light_ctx = context_for_preset(UiThemePreset::Daylight);
4147 let dark_hl = dark_ctx.syntax_highlight_theme();
4148 let light_hl = light_ctx.syntax_highlight_theme();
4149 assert_ne!(
4151 format!("{:?}", dark_hl.keyword.fg),
4152 format!("{:?}", light_hl.keyword.fg),
4153 "dark and light syntax themes should differ"
4154 );
4155 }
4156
4157 #[test]
4158 fn markdown_theme_differs_across_presets() {
4159 let mut themes = std::collections::HashSet::new();
4160 for preset in UiThemePreset::all() {
4161 let ctx = context_for_preset(preset);
4162 let md = ctx.markdown_theme();
4163 themes.insert(format!("{:?}", md.h1.fg));
4164 }
4165 assert!(
4166 themes.len() >= 3,
4167 "markdown h1 should differ across presets, got {} distinct",
4168 themes.len()
4169 );
4170 }
4171
4172 #[test]
4173 fn markdown_theme_not_default() {
4174 let ctx = context_for_preset(UiThemePreset::TokyoNight);
4175 let themed = ctx.markdown_theme();
4176 let default = MarkdownTheme::default();
4177 assert_ne!(
4178 format!("{:?}", themed.h1),
4179 format!("{:?}", default.h1),
4180 "themed markdown h1 should differ from default"
4181 );
4182 }
4183
4184 const ALL_STYLE_TOKENS: &[(&str, &str)] = &[
4192 ("STYLE_APP_ROOT", STYLE_APP_ROOT),
4193 ("STYLE_PANE_BASE", STYLE_PANE_BASE),
4194 ("STYLE_PANE_FOCUSED", STYLE_PANE_FOCUSED),
4195 ("STYLE_PANE_TITLE_FOCUSED", STYLE_PANE_TITLE_FOCUSED),
4196 ("STYLE_PANE_TITLE_UNFOCUSED", STYLE_PANE_TITLE_UNFOCUSED),
4197 ("STYLE_SPLIT_HANDLE", STYLE_SPLIT_HANDLE),
4198 ("STYLE_TEXT_PRIMARY", STYLE_TEXT_PRIMARY),
4199 ("STYLE_TEXT_MUTED", STYLE_TEXT_MUTED),
4200 ("STYLE_TEXT_SUBTLE", STYLE_TEXT_SUBTLE),
4201 ("STYLE_STATUS_SUCCESS", STYLE_STATUS_SUCCESS),
4202 ("STYLE_STATUS_WARNING", STYLE_STATUS_WARNING),
4203 ("STYLE_STATUS_ERROR", STYLE_STATUS_ERROR),
4204 ("STYLE_STATUS_INFO", STYLE_STATUS_INFO),
4205 ("STYLE_RESULT_ROW", STYLE_RESULT_ROW),
4206 ("STYLE_RESULT_ROW_ALT", STYLE_RESULT_ROW_ALT),
4207 ("STYLE_RESULT_ROW_SELECTED", STYLE_RESULT_ROW_SELECTED),
4208 ("STYLE_ROLE_USER", STYLE_ROLE_USER),
4209 ("STYLE_ROLE_ASSISTANT", STYLE_ROLE_ASSISTANT),
4210 ("STYLE_ROLE_TOOL", STYLE_ROLE_TOOL),
4211 ("STYLE_ROLE_SYSTEM", STYLE_ROLE_SYSTEM),
4212 ("STYLE_ROLE_GUTTER_USER", STYLE_ROLE_GUTTER_USER),
4213 ("STYLE_ROLE_GUTTER_ASSISTANT", STYLE_ROLE_GUTTER_ASSISTANT),
4214 ("STYLE_ROLE_GUTTER_TOOL", STYLE_ROLE_GUTTER_TOOL),
4215 ("STYLE_ROLE_GUTTER_SYSTEM", STYLE_ROLE_GUTTER_SYSTEM),
4216 ("STYLE_SCORE_HIGH", STYLE_SCORE_HIGH),
4217 ("STYLE_SCORE_MID", STYLE_SCORE_MID),
4218 ("STYLE_SCORE_LOW", STYLE_SCORE_LOW),
4219 ("STYLE_SOURCE_LOCAL", STYLE_SOURCE_LOCAL),
4220 ("STYLE_SOURCE_REMOTE", STYLE_SOURCE_REMOTE),
4221 ("STYLE_LOCATION", STYLE_LOCATION),
4222 ("STYLE_PILL_ACTIVE", STYLE_PILL_ACTIVE),
4223 ("STYLE_PILL_INACTIVE", STYLE_PILL_INACTIVE),
4224 ("STYLE_PILL_LABEL", STYLE_PILL_LABEL),
4225 ("STYLE_CRUMB_ACTIVE", STYLE_CRUMB_ACTIVE),
4226 ("STYLE_CRUMB_INACTIVE", STYLE_CRUMB_INACTIVE),
4227 ("STYLE_CRUMB_SEPARATOR", STYLE_CRUMB_SEPARATOR),
4228 ("STYLE_TAB_ACTIVE", STYLE_TAB_ACTIVE),
4229 ("STYLE_TAB_INACTIVE", STYLE_TAB_INACTIVE),
4230 ("STYLE_DETAIL_FIND_CONTAINER", STYLE_DETAIL_FIND_CONTAINER),
4231 ("STYLE_DETAIL_FIND_QUERY", STYLE_DETAIL_FIND_QUERY),
4232 (
4233 "STYLE_DETAIL_FIND_MATCH_ACTIVE",
4234 STYLE_DETAIL_FIND_MATCH_ACTIVE,
4235 ),
4236 (
4237 "STYLE_DETAIL_FIND_MATCH_INACTIVE",
4238 STYLE_DETAIL_FIND_MATCH_INACTIVE,
4239 ),
4240 ("STYLE_QUERY_HIGHLIGHT", STYLE_QUERY_HIGHLIGHT),
4241 ("STYLE_KBD_KEY", STYLE_KBD_KEY),
4242 ("STYLE_KBD_DESC", STYLE_KBD_DESC),
4243 ("STYLE_SEARCH_FOCUS", STYLE_SEARCH_FOCUS),
4244 ("STYLE_MODAL_BACKDROP", STYLE_MODAL_BACKDROP),
4245 ];
4246
4247 const INDIRECT_USE_WHITELIST: &[&str] = &[
4252 "STYLE_SCORE_HIGH",
4254 "STYLE_SCORE_MID",
4255 "STYLE_SCORE_LOW",
4256 "STYLE_DETAIL_FIND_CONTAINER",
4259 "STYLE_DETAIL_FIND_QUERY",
4260 "STYLE_DETAIL_FIND_MATCH_ACTIVE",
4261 "STYLE_DETAIL_FIND_MATCH_INACTIVE",
4262 "STYLE_QUERY_HIGHLIGHT",
4263 "STYLE_PILL_LABEL",
4265 ];
4266
4267 #[test]
4268 fn style_token_registry_is_complete() {
4269 let source = std::fs::read_to_string(
4272 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ui/style_system.rs"),
4273 )
4274 .expect("should be able to read style_system.rs");
4275
4276 let mut defined_in_source: Vec<String> = Vec::new();
4277 for line in source.lines() {
4278 let trimmed = line.trim();
4279 if trimmed.starts_with("pub const STYLE_")
4280 && trimmed.contains(": &str")
4281 && let Some(name) = trimmed
4282 .strip_prefix("pub const ")
4283 .and_then(|s| s.split(':').next())
4284 {
4285 defined_in_source.push(name.trim().to_string());
4286 }
4287 }
4288
4289 let registry_names: Vec<&str> = ALL_STYLE_TOKENS.iter().map(|(name, _)| *name).collect();
4290
4291 for src_name in &defined_in_source {
4293 assert!(
4294 registry_names.contains(&src_name.as_str()),
4295 "Style token `{src_name}` is defined in source but missing from \
4296 ALL_STYLE_TOKENS registry. Add it to the test registry."
4297 );
4298 }
4299
4300 for reg_name in ®istry_names {
4302 assert!(
4303 defined_in_source.iter().any(|s| s == reg_name),
4304 "ALL_STYLE_TOKENS contains `{reg_name}` but it is not defined \
4305 as `pub const` in source. Remove it from the test registry."
4306 );
4307 }
4308 }
4309
4310 #[test]
4311 fn no_dead_style_tokens() {
4312 let ui_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ui");
4314 let mut rendering_source = String::new();
4315
4316 for entry in std::fs::read_dir(&ui_dir).expect("should read src/ui/") {
4317 let entry = entry.expect("dir entry");
4318 let path = entry.path();
4319 if path.extension().is_some_and(|e| e == "rs")
4320 && path.file_name().is_some_and(|n| n != "style_system.rs")
4321 {
4322 rendering_source.push_str(
4323 &std::fs::read_to_string(&path)
4324 .unwrap_or_else(|_| panic!("should read {}", path.display())),
4325 );
4326 }
4327 }
4328
4329 let self_source = std::fs::read_to_string(ui_dir.join("style_system.rs"))
4332 .expect("should read style_system.rs");
4333
4334 let mut dead_tokens: Vec<&str> = Vec::new();
4335
4336 for (const_name, _token_value) in ALL_STYLE_TOKENS {
4337 if INDIRECT_USE_WHITELIST.contains(const_name) {
4338 continue;
4339 }
4340
4341 let in_rendering = rendering_source.contains(const_name);
4346 let in_self_methods = self_source.lines().any(|line| {
4347 line.contains(const_name)
4348 && !line.trim().starts_with("pub const ")
4349 && !line.trim().starts_with("//")
4350 && !line.contains("ALL_STYLE_TOKENS")
4351 && !line.contains("INDIRECT_USE_WHITELIST")
4352 });
4353
4354 if !in_rendering && !in_self_methods {
4355 dead_tokens.push(const_name);
4356 }
4357 }
4358
4359 assert!(
4360 dead_tokens.is_empty(),
4361 "Dead style tokens found (defined but never used in rendering code):\n \
4362 {}\n\n\
4363 Fix: Either wire these tokens into rendering code in src/ui/app.rs,\n\
4364 or add them to INDIRECT_USE_WHITELIST with a justification comment\n\
4365 if they are consumed indirectly (e.g. via helper methods).",
4366 dead_tokens.join("\n ")
4367 );
4368 }
4369
4370 #[test]
4371 fn all_tokens_resolve_to_non_default_style() {
4372 for preset in UiThemePreset::all() {
4376 let ctx = context_for_preset(preset);
4377 for (const_name, token_value) in ALL_STYLE_TOKENS {
4378 let style = ctx.style(token_value);
4379 assert!(
4380 style.fg.is_some() || style.bg.is_some(),
4381 "Token {const_name} resolves to a style with no fg or bg \
4382 for preset {} — it may be unwired in build_stylesheet()",
4383 preset.name()
4384 );
4385 }
4386 }
4387 }
4388
4389 #[test]
4392 fn role_tokens_are_pairwise_distinct_per_preset() {
4393 let role_tokens = [
4394 ("user", STYLE_ROLE_USER),
4395 ("assistant", STYLE_ROLE_ASSISTANT),
4396 ("tool", STYLE_ROLE_TOOL),
4397 ("system", STYLE_ROLE_SYSTEM),
4398 ];
4399 for preset in UiThemePreset::all() {
4400 let ctx = context_for_preset(preset);
4401 for i in 0..role_tokens.len() {
4402 for j in (i + 1)..role_tokens.len() {
4403 let (name_a, token_a) = role_tokens[i];
4404 let (name_b, token_b) = role_tokens[j];
4405 let style_a = ctx.style(token_a);
4406 let style_b = ctx.style(token_b);
4407 assert_ne!(
4408 style_a.fg,
4409 style_b.fg,
4410 "Role {name_a} and {name_b} must have distinct fg colors \
4411 for preset {} to remain visually distinguishable",
4412 preset.name()
4413 );
4414 }
4415 }
4416 }
4417 }
4418
4419 #[test]
4420 fn role_gutter_tokens_are_pairwise_distinct_per_preset() {
4421 let gutter_tokens = [
4422 ("user", STYLE_ROLE_GUTTER_USER),
4423 ("assistant", STYLE_ROLE_GUTTER_ASSISTANT),
4424 ("tool", STYLE_ROLE_GUTTER_TOOL),
4425 ("system", STYLE_ROLE_GUTTER_SYSTEM),
4426 ];
4427 for preset in UiThemePreset::all() {
4428 let ctx = context_for_preset(preset);
4429 for i in 0..gutter_tokens.len() {
4430 for j in (i + 1)..gutter_tokens.len() {
4431 let (name_a, token_a) = gutter_tokens[i];
4432 let (name_b, token_b) = gutter_tokens[j];
4433 let style_a = ctx.style(token_a);
4434 let style_b = ctx.style(token_b);
4435 assert_ne!(
4436 style_a.fg,
4437 style_b.fg,
4438 "Gutter {name_a} and {name_b} must have distinct fg colors \
4439 for preset {} to remain scannable",
4440 preset.name()
4441 );
4442 }
4443 }
4444 }
4445 }
4446
4447 #[test]
4448 fn status_tokens_are_pairwise_distinct_per_preset() {
4449 let status_tokens = [
4450 ("success", STYLE_STATUS_SUCCESS),
4451 ("warning", STYLE_STATUS_WARNING),
4452 ("error", STYLE_STATUS_ERROR),
4453 ("info", STYLE_STATUS_INFO),
4454 ];
4455 for preset in UiThemePreset::all() {
4456 let ctx = context_for_preset(preset);
4457 for i in 0..status_tokens.len() {
4458 for j in (i + 1)..status_tokens.len() {
4459 let (name_a, token_a) = status_tokens[i];
4460 let (name_b, token_b) = status_tokens[j];
4461 let style_a = ctx.style(token_a);
4462 let style_b = ctx.style(token_b);
4463 assert_ne!(
4464 style_a.fg,
4465 style_b.fg,
4466 "Status {name_a} and {name_b} must have distinct fg colors \
4467 for preset {}",
4468 preset.name()
4469 );
4470 }
4471 }
4472 }
4473 }
4474
4475 #[test]
4476 fn text_hierarchy_is_ordered_per_preset() {
4477 for preset in UiThemePreset::all() {
4480 let ctx = context_for_preset(preset);
4481 let primary = ctx.style(STYLE_TEXT_PRIMARY);
4482 let muted = ctx.style(STYLE_TEXT_MUTED);
4483 let subtle = ctx.style(STYLE_TEXT_SUBTLE);
4484
4485 assert_ne!(
4486 primary.fg,
4487 muted.fg,
4488 "TEXT_PRIMARY and TEXT_MUTED must differ for preset {}",
4489 preset.name()
4490 );
4491 assert_ne!(
4492 muted.fg,
4493 subtle.fg,
4494 "TEXT_MUTED and TEXT_SUBTLE must differ for preset {}",
4495 preset.name()
4496 );
4497 assert_ne!(
4498 primary.fg,
4499 subtle.fg,
4500 "TEXT_PRIMARY and TEXT_SUBTLE must differ for preset {}",
4501 preset.name()
4502 );
4503 }
4504 }
4505
4506 #[test]
4507 fn score_tokens_form_visual_hierarchy() {
4508 for preset in UiThemePreset::all() {
4509 let ctx = context_for_preset(preset);
4510 let high = ctx.style(STYLE_SCORE_HIGH);
4511 let mid = ctx.style(STYLE_SCORE_MID);
4512 let low = ctx.style(STYLE_SCORE_LOW);
4513
4514 assert_ne!(
4515 high.fg,
4516 mid.fg,
4517 "SCORE_HIGH and SCORE_MID must differ for preset {}",
4518 preset.name()
4519 );
4520 assert_ne!(
4521 mid.fg,
4522 low.fg,
4523 "SCORE_MID and SCORE_LOW must differ for preset {}",
4524 preset.name()
4525 );
4526 assert!(
4528 high.has_attr(ftui::StyleFlags::BOLD),
4529 "SCORE_HIGH should be bold for preset {}",
4530 preset.name()
4531 );
4532 }
4533 }
4534
4535 #[test]
4536 fn default_presets_pass_contrast_report() {
4537 for preset in UiThemePreset::all() {
4540 let ctx = context_for_preset(preset);
4541 let report = ctx.contrast_report();
4542 assert!(
4543 !report.has_failures(),
4544 "Preset {} fails contrast checks: {:?}",
4545 preset.name(),
4546 report.failing_pairs().into_iter().collect::<Vec<_>>()
4547 );
4548 }
4549 }
4550
4551 #[test]
4552 fn palette_propagation_is_deterministic() {
4553 for preset in UiThemePreset::all() {
4555 let ctx1 = context_for_preset(preset);
4556 let ctx2 = context_for_preset(preset);
4557 for (_const_name, token_value) in ALL_STYLE_TOKENS {
4558 let s1 = ctx1.style(token_value);
4559 let s2 = ctx2.style(token_value);
4560 assert_eq!(
4561 format!("{s1:?}"),
4562 format!("{s2:?}"),
4563 "Token {_const_name} is not deterministic for preset {}",
4564 preset.name()
4565 );
4566 }
4567 }
4568 }
4569
4570 #[test]
4575 fn rendering_token_affordance_matrix_with_logging() {
4576 use super::super::test_log::{Category, TestLogger};
4577
4578 let log = TestLogger::new("11.1.affordance_matrix");
4579
4580 for preset in UiThemePreset::all() {
4581 let ctx = context_for_preset(preset);
4582 log.step_start(Category::Style, format!(r#""preset:{:?}""#, preset));
4583
4584 let pill_active = ctx.style(STYLE_PILL_ACTIVE);
4586 if pill_active.bg.is_some() {
4587 log.pass(
4588 Category::Style,
4589 format!(r#""pill_active bg present for {:?}""#, preset),
4590 );
4591 } else {
4592 log.fail(
4593 Category::Style,
4594 format!(
4595 r#"{{"msg":"pill_active bg missing","preset":"{:?}"}}"#,
4596 preset
4597 ),
4598 );
4599 panic!("STYLE_PILL_ACTIVE must have bg for {:?}", preset);
4600 }
4601
4602 let tab_active = ctx.style(STYLE_TAB_ACTIVE);
4604 if tab_active.bg.is_some() {
4605 log.pass(
4606 Category::Style,
4607 format!(r#""tab_active bg present for {:?}""#, preset),
4608 );
4609 } else {
4610 log.fail(
4611 Category::Style,
4612 format!(
4613 r#"{{"msg":"tab_active bg missing","preset":"{:?}"}}"#,
4614 preset
4615 ),
4616 );
4617 panic!("STYLE_TAB_ACTIVE must have bg for {:?}", preset);
4618 }
4619
4620 let tab_inactive = ctx.style(STYLE_TAB_INACTIVE);
4622 if tab_active.fg != tab_inactive.fg || tab_active.bg != tab_inactive.bg {
4623 log.pass(
4624 Category::Style,
4625 format!(r#""tab active/inactive distinct for {:?}""#, preset),
4626 );
4627 } else {
4628 log.fail(
4629 Category::Style,
4630 format!(
4631 r#"{{"msg":"tab active/inactive identical","preset":"{:?}"}}"#,
4632 preset
4633 ),
4634 );
4635 panic!("TAB_ACTIVE and TAB_INACTIVE must differ for {:?}", preset);
4636 }
4637
4638 let high = ctx.style(STYLE_SCORE_HIGH);
4640 let mid = ctx.style(STYLE_SCORE_MID);
4641 let low = ctx.style(STYLE_SCORE_LOW);
4642 if high.fg != mid.fg && mid.fg != low.fg {
4643 log.pass(
4644 Category::Style,
4645 format!(r#""score hierarchy preserved for {:?}""#, preset),
4646 );
4647 } else {
4648 log.fail(
4649 Category::Style,
4650 format!(
4651 r#"{{"msg":"score hierarchy broken","preset":"{:?}"}}"#,
4652 preset
4653 ),
4654 );
4655 panic!(
4656 "Score HIGH/MID/LOW must be pairwise distinct for {:?}",
4657 preset
4658 );
4659 }
4660
4661 let user = ctx.style(STYLE_ROLE_GUTTER_USER);
4663 let asst = ctx.style(STYLE_ROLE_GUTTER_ASSISTANT);
4664 let tool = ctx.style(STYLE_ROLE_GUTTER_TOOL);
4665 let sys = ctx.style(STYLE_ROLE_GUTTER_SYSTEM);
4666 let roles = [
4667 ("user", user.fg),
4668 ("assistant", asst.fg),
4669 ("tool", tool.fg),
4670 ("system", sys.fg),
4671 ];
4672 let mut distinct = true;
4673 for i in 0..roles.len() {
4674 for j in (i + 1)..roles.len() {
4675 if roles[i].1 == roles[j].1 {
4676 distinct = false;
4677 }
4678 }
4679 }
4680 if distinct {
4681 log.pass(
4682 Category::Style,
4683 format!(r#""role gutters pairwise distinct for {:?}""#, preset),
4684 );
4685 } else {
4686 log.fail(
4687 Category::Style,
4688 format!(
4689 r#"{{"msg":"role gutters not pairwise distinct","preset":"{:?}"}}"#,
4690 preset
4691 ),
4692 );
4693 panic!("Role gutters must be pairwise distinct for {:?}", preset);
4694 }
4695
4696 log.step_end(Category::Style, format!(r#""preset:{:?} done""#, preset));
4697 }
4698
4699 let (pass, fail, _) = log.summary();
4700 assert!(
4701 fail == 0,
4702 "rendering affordance matrix: {pass} pass, {fail} fail"
4703 );
4704 }
4705
4706 #[test]
4707 fn markdown_theme_preset_coherence_with_logging() {
4708 use super::super::test_log::{Category, TestLogger};
4709
4710 let log = TestLogger::new("11.1.markdown_coherence");
4711
4712 for preset in UiThemePreset::all() {
4713 let ctx = context_for_preset(preset);
4714 let md_theme = ctx.markdown_theme();
4715
4716 let default_md = MarkdownTheme::default();
4718 if format!("{:?}", md_theme) != format!("{:?}", default_md) {
4719 log.pass(
4720 Category::Theme,
4721 format!(r#""markdown_theme non-default for {:?}""#, preset),
4722 );
4723 } else {
4724 log.fail(
4725 Category::Theme,
4726 format!(
4727 r#"{{"msg":"markdown_theme is default","preset":"{:?}"}}"#,
4728 preset
4729 ),
4730 );
4731 panic!("markdown_theme() must be non-default for {:?}", preset);
4732 }
4733
4734 if md_theme.code_inline.bg.is_some() {
4736 log.pass(
4737 Category::Theme,
4738 format!(r#""code_inline has bg for {:?}""#, preset),
4739 );
4740 } else {
4741 log.fail(
4742 Category::Theme,
4743 format!(
4744 r#"{{"msg":"code_inline bg missing","preset":"{:?}"}}"#,
4745 preset
4746 ),
4747 );
4748 panic!("code_inline must have bg for {:?}", preset);
4749 }
4750 }
4751
4752 let (pass, fail, _) = log.summary();
4753 assert!(fail == 0, "markdown coherence: {pass} pass, {fail} fail");
4754 }
4755
4756 #[test]
4757 fn degradation_affordance_preservation_with_logging() {
4758 use super::super::test_log::{Category, TestLogger};
4759 use crate::ui::app::LayoutBreakpoint as LB;
4760 use ftui::render::budget::DegradationLevel;
4761
4762 let log = TestLogger::new("11.1.degradation_affordance");
4763 let opts = StyleOptions {
4764 color_profile: ColorProfile::TrueColor,
4765 ..StyleOptions::default()
4766 };
4767
4768 let full_policy = DecorativePolicy::resolve(opts, DegradationLevel::Full, LB::Wide, true);
4770 if full_policy.use_gradients && full_policy.show_icons {
4771 log.pass(
4772 Category::Degradation,
4773 r#""Full allows gradients+icons""#.to_string(),
4774 );
4775 } else {
4776 log.fail(
4777 Category::Degradation,
4778 format!(
4779 r#"{{"msg":"Full degradation restricts decorations","gradients":{},"icons":{}}}"#,
4780 full_policy.use_gradients, full_policy.show_icons
4781 ),
4782 );
4783 }
4784
4785 let essential_policy =
4787 DecorativePolicy::resolve(opts, DegradationLevel::EssentialOnly, LB::Wide, true);
4788 if !essential_policy.use_gradients {
4789 log.pass(
4790 Category::Degradation,
4791 r#""EssentialOnly restricts gradients""#.to_string(),
4792 );
4793 } else {
4794 log.fail(
4795 Category::Degradation,
4796 r#""EssentialOnly should restrict gradients""#.to_string(),
4797 );
4798 }
4799
4800 let (pass, fail, _) = log.summary();
4801 assert!(
4802 fail == 0,
4803 "degradation affordance: {pass} pass, {fail} fail"
4804 );
4805 }
4806}