Skip to main content

coding_agent_search/ui/
theme.rs

1//! ftui theme system for cass.
2//!
3//! Bridges the existing `ThemePalette` color definitions to ftui's `Theme`,
4//! `StyleSheet`, and `ColorProfile` infrastructure so every widget draws from
5//! the same token source.
6//!
7//! # Design goals
8//! - Single source of truth: all panes consume styles from [`CassTheme`].
9//! - Terminal-aware: truecolor terminals get the premium palette; 8/16-color
10//!   and mono terminals get safe automatic fallbacks via `ColorProfile`.
11//! - Env-var overrides: respects `NO_COLOR`, `CASS_NO_COLOR`, `CASS_NO_ICONS`,
12//!   `CASS_NO_GRADIENT`, `CASS_DISABLE_ANIMATIONS`, and `CASS_A11Y`.
13//! - Preset cycling: all nineteen `ThemePreset` variants produce a valid ftui Theme.
14
15use ftui::render::cell::PackedRgba;
16use ftui::{Color, ColorCache, ColorProfile, Style, StyleSheet, Theme};
17
18use crate::ui::components::theme::{self as legacy, ThemePalette, ThemePreset};
19
20// ─── Environment variable names ──────────────────────────────────────────────
21
22const ENV_NO_COLOR: &str = "NO_COLOR";
23const ENV_CASS_NO_COLOR: &str = "CASS_NO_COLOR";
24const ENV_CASS_NO_ICONS: &str = "CASS_NO_ICONS";
25const ENV_CASS_NO_GRADIENT: &str = "CASS_NO_GRADIENT";
26const ENV_CASS_DISABLE_ANIMATIONS: &str = "CASS_DISABLE_ANIMATIONS";
27const ENV_CASS_ANIM: &str = "CASS_ANIM";
28const ENV_CASS_A11Y: &str = "CASS_A11Y";
29
30// ─── Named style IDs ────────────────────────────────────────────────────────
31
32/// Well-known style names registered in the [`StyleSheet`].
33pub mod style_ids {
34    // Text hierarchy
35    pub const TEXT_PRIMARY: &str = "text.primary";
36    pub const TEXT_SECONDARY: &str = "text.secondary";
37    pub const TEXT_MUTED: &str = "text.muted";
38    pub const TEXT_DISABLED: &str = "text.disabled";
39
40    // Accents
41    pub const ACCENT_PRIMARY: &str = "accent.primary";
42    pub const ACCENT_SECONDARY: &str = "accent.secondary";
43    pub const ACCENT_TERTIARY: &str = "accent.tertiary";
44
45    // Surfaces
46    pub const BG_DEEP: &str = "bg.deep";
47    pub const BG_SURFACE: &str = "bg.surface";
48    pub const BG_HIGHLIGHT: &str = "bg.highlight";
49
50    // Borders
51    pub const BORDER: &str = "border";
52    pub const BORDER_FOCUS: &str = "border.focus";
53    pub const BORDER_MINIMAL: &str = "border.minimal";
54    pub const BORDER_EMPHASIZED: &str = "border.emphasized";
55
56    // Roles
57    pub const ROLE_USER: &str = "role.user";
58    pub const ROLE_AGENT: &str = "role.agent";
59    pub const ROLE_TOOL: &str = "role.tool";
60    pub const ROLE_SYSTEM: &str = "role.system";
61
62    // Role backgrounds
63    pub const ROLE_USER_BG: &str = "role.user.bg";
64    pub const ROLE_AGENT_BG: &str = "role.agent.bg";
65    pub const ROLE_TOOL_BG: &str = "role.tool.bg";
66    pub const ROLE_SYSTEM_BG: &str = "role.system.bg";
67
68    // Status
69    pub const STATUS_SUCCESS: &str = "status.success";
70    pub const STATUS_WARNING: &str = "status.warning";
71    pub const STATUS_ERROR: &str = "status.error";
72    pub const STATUS_INFO: &str = "status.info";
73
74    // Interaction
75    pub const HIGHLIGHT: &str = "highlight";
76    pub const SELECTED: &str = "selected";
77    pub const CHIP: &str = "chip";
78    pub const KBD: &str = "kbd";
79    pub const CODE: &str = "code";
80
81    // Zebra stripes
82    pub const STRIPE_EVEN: &str = "stripe.even";
83    pub const STRIPE_ODD: &str = "stripe.odd";
84
85    // Gradient (header)
86    pub const GRADIENT_TOP: &str = "gradient.top";
87    pub const GRADIENT_MID: &str = "gradient.mid";
88    pub const GRADIENT_BOT: &str = "gradient.bot";
89}
90
91// ─── Feature flags ───────────────────────────────────────────────────────────
92
93/// Runtime feature flags derived from environment variables.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub struct ThemeFlags {
96    /// Disable all color output (`NO_COLOR` or `CASS_NO_COLOR`).
97    pub no_color: bool,
98    /// Disable emoji/icon glyphs (`CASS_NO_ICONS`).
99    pub no_icons: bool,
100    /// Disable gradient simulation (`CASS_NO_GRADIENT`).
101    pub no_gradient: bool,
102    /// Disable animations (`CASS_DISABLE_ANIMATIONS` or `CASS_ANIM=0`).
103    pub no_animations: bool,
104    /// Accessibility mode (`CASS_A11Y=1`): textual cues supplement color.
105    pub a11y: bool,
106}
107
108impl ThemeFlags {
109    /// Detect flags from the process environment.
110    pub fn detect() -> Self {
111        Self {
112            no_color: std::env::var_os(ENV_NO_COLOR).is_some() || env_truthy(ENV_CASS_NO_COLOR),
113            no_icons: env_truthy(ENV_CASS_NO_ICONS),
114            no_gradient: env_truthy(ENV_CASS_NO_GRADIENT),
115            no_animations: env_truthy(ENV_CASS_DISABLE_ANIMATIONS) || env_is(ENV_CASS_ANIM, "0"),
116            a11y: env_truthy(ENV_CASS_A11Y),
117        }
118    }
119
120    /// Build flags from explicit values (for testing).
121    pub fn custom(
122        no_color: bool,
123        no_icons: bool,
124        no_gradient: bool,
125        no_animations: bool,
126        a11y: bool,
127    ) -> Self {
128        Self {
129            no_color,
130            no_icons,
131            no_gradient,
132            no_animations,
133            a11y,
134        }
135    }
136
137    /// All features enabled (no restrictions).
138    pub fn all_enabled() -> Self {
139        Self {
140            no_color: false,
141            no_icons: false,
142            no_gradient: false,
143            no_animations: false,
144            a11y: false,
145        }
146    }
147}
148
149impl Default for ThemeFlags {
150    fn default() -> Self {
151        Self::all_enabled()
152    }
153}
154
155// ─── CassTheme ──────────────────────────────────────────────────────────────
156
157/// Central theme object consumed by every ftui widget in cass.
158///
159/// Wraps an ftui [`Theme`], a [`StyleSheet`] with named styles, the detected
160/// [`ColorProfile`], and runtime [`ThemeFlags`]. All rendering code should
161/// query styles through this struct rather than hard-coding colors.
162pub struct CassTheme {
163    /// Current preset (for cycling).
164    pub preset: ThemePreset,
165    /// Whether dark mode is active.
166    pub is_dark: bool,
167    /// ftui Theme with semantic color slots.
168    pub theme: Theme,
169    /// Named style registry - the single source of truth for widget styles.
170    pub styles: StyleSheet,
171    /// Detected terminal color capability.
172    pub profile: ColorProfile,
173    /// Color downgrade cache (speeds up repeated color conversions).
174    pub color_cache: ColorCache,
175    /// Runtime feature flags from environment.
176    pub flags: ThemeFlags,
177}
178
179impl CassTheme {
180    /// Build a theme from a preset, detecting color profile and env flags.
181    pub fn from_preset(preset: ThemePreset) -> Self {
182        let flags = ThemeFlags::detect();
183        let profile = if flags.no_color {
184            ColorProfile::Mono
185        } else {
186            ColorProfile::detect()
187        };
188        Self::with_options(preset, profile, flags)
189    }
190
191    /// Build a theme with explicit profile and flags (for testing / headless).
192    pub fn with_options(preset: ThemePreset, profile: ColorProfile, flags: ThemeFlags) -> Self {
193        let palette = preset.to_palette();
194        let is_dark = !matches!(preset, ThemePreset::Daylight | ThemePreset::SolarizedLight);
195        let theme = build_ftui_theme(&palette, is_dark);
196        let styles = build_stylesheet(&palette, is_dark, &flags);
197        let color_cache = ColorCache::new(profile);
198
199        Self {
200            preset,
201            is_dark,
202            theme,
203            styles,
204            profile,
205            color_cache,
206            flags,
207        }
208    }
209
210    /// Cycle to the next preset and rebuild.
211    pub fn next_preset(&mut self) {
212        self.preset = self.preset.next();
213        self.rebuild();
214    }
215
216    /// Cycle to the previous preset and rebuild.
217    pub fn prev_preset(&mut self) {
218        self.preset = self.preset.prev();
219        self.rebuild();
220    }
221
222    /// Rebuild theme + stylesheet from current preset/profile/flags.
223    fn rebuild(&mut self) {
224        let palette = self.preset.to_palette();
225        self.is_dark = !matches!(
226            self.preset,
227            ThemePreset::Daylight | ThemePreset::SolarizedLight
228        );
229        self.theme = build_ftui_theme(&palette, self.is_dark);
230        self.styles = build_stylesheet(&palette, self.is_dark, &self.flags);
231        self.color_cache = ColorCache::new(self.profile);
232    }
233
234    /// Get an ftui [`Style`] by name from the stylesheet, falling back to
235    /// `Style::default()` if not found.
236    pub fn style(&self, name: &str) -> Style {
237        self.styles.get_or_default(name)
238    }
239
240    /// Compose multiple named styles left-to-right (later overrides earlier).
241    pub fn compose(&self, names: &[&str]) -> Style {
242        self.styles.compose(names)
243    }
244
245    /// Downgrade an RGB color to the terminal's color profile.
246    pub fn downgrade(&mut self, color: Color) -> Color {
247        color.downgrade(self.profile)
248    }
249
250    /// Get the legacy [`ThemePalette`] for code that hasn't migrated yet.
251    pub fn legacy_palette(&self) -> ThemePalette {
252        self.preset.to_palette()
253    }
254
255    /// Whether emoji icons should be shown.
256    pub fn show_icons(&self) -> bool {
257        !self.flags.no_icons
258    }
259
260    /// Whether gradient simulation should be used.
261    pub fn show_gradient(&self) -> bool {
262        !self.flags.no_gradient && self.profile.supports_true_color()
263    }
264
265    /// Whether animations should play.
266    pub fn show_animations(&self) -> bool {
267        !self.flags.no_animations
268    }
269
270    /// Whether accessibility mode is active (textual cues supplement color).
271    pub fn a11y_mode(&self) -> bool {
272        self.flags.a11y
273    }
274
275    /// Get the agent icon glyph, respecting `no_icons` flag.
276    pub fn agent_icon(&self, agent: &str) -> &'static str {
277        if self.flags.no_icons {
278            ""
279        } else {
280            ThemePalette::agent_icon(agent)
281        }
282    }
283
284    /// Get a role-specific ftui [`Style`] for message rendering.
285    pub fn role_style(&self, role: &str) -> Style {
286        let id = match role.to_lowercase().as_str() {
287            "user" => style_ids::ROLE_USER,
288            "assistant" | "agent" => style_ids::ROLE_AGENT,
289            "tool" => style_ids::ROLE_TOOL,
290            "system" => style_ids::ROLE_SYSTEM,
291            _ => style_ids::TEXT_MUTED,
292        };
293        self.style(id)
294    }
295
296    /// Get role background style.
297    pub fn role_bg_style(&self, role: &str) -> Style {
298        let id = match role.to_lowercase().as_str() {
299            "user" => style_ids::ROLE_USER_BG,
300            "assistant" | "agent" => style_ids::ROLE_AGENT_BG,
301            "tool" => style_ids::ROLE_TOOL_BG,
302            "system" => style_ids::ROLE_SYSTEM_BG,
303            _ => style_ids::BG_DEEP,
304        };
305        self.style(id)
306    }
307
308    /// Get a pane style for a specific agent. Returns (bg_only, bg+fg) styles.
309    pub fn agent_pane_style(&self, agent: &str) -> (Style, Style) {
310        let pane = ThemePalette::agent_pane(agent);
311        let bg = Style::new().bg(pane.bg);
312        let fg = Style::new().fg(pane.fg).bg(pane.bg);
313        (bg, fg)
314    }
315
316    /// Get a zebra-stripe background style for a given row index.
317    pub fn stripe_style(&self, row_idx: usize) -> Style {
318        if row_idx.is_multiple_of(2) {
319            self.style(style_ids::STRIPE_EVEN)
320        } else {
321            self.style(style_ids::STRIPE_ODD)
322        }
323    }
324}
325
326impl Default for CassTheme {
327    fn default() -> Self {
328        Self::from_preset(ThemePreset::default())
329    }
330}
331
332// ─── Theme builder ──────────────────────────────────────────────────────────
333
334/// Convert a legacy cass `ThemePalette` into an ftui `Theme`.
335fn build_ftui_theme(palette: &ThemePalette, is_dark: bool) -> Theme {
336    // PackedRgba → ftui::Color via From impl
337    let c = |color: PackedRgba| -> Color { color.into() };
338
339    Theme::builder()
340        .primary(c(palette.accent))
341        .secondary(c(palette.accent_alt))
342        .accent(c(palette.accent))
343        .background(c(palette.bg))
344        .surface(c(palette.surface))
345        .overlay(c(palette.surface))
346        .text(c(palette.fg))
347        .text_muted(c(palette.hint))
348        .text_subtle(if is_dark {
349            c(legacy::colors::TEXT_DISABLED)
350        } else {
351            Color::rgb(180, 180, 190)
352        })
353        .success(c(legacy::colors::STATUS_SUCCESS))
354        .warning(c(legacy::colors::STATUS_WARNING))
355        .error(c(legacy::colors::STATUS_ERROR))
356        .info(c(legacy::colors::STATUS_INFO))
357        .border(c(palette.border))
358        .border_focused(c(legacy::colors::BORDER_FOCUS))
359        .selection_bg(if is_dark {
360            c(legacy::colors::BG_HIGHLIGHT)
361        } else {
362            Color::rgb(210, 215, 230)
363        })
364        .selection_fg(c(palette.fg))
365        .scrollbar_track(c(palette.surface))
366        .scrollbar_thumb(c(palette.border))
367        .build()
368}
369
370/// Build the named-style registry from a palette.
371fn build_stylesheet(palette: &ThemePalette, is_dark: bool, flags: &ThemeFlags) -> StyleSheet {
372    let sheet = StyleSheet::new();
373
374    // Text hierarchy
375    sheet.define(style_ids::TEXT_PRIMARY, Style::new().fg(palette.fg));
376    sheet.define(
377        style_ids::TEXT_SECONDARY,
378        Style::new().fg(if is_dark {
379            legacy::colors::TEXT_SECONDARY
380        } else {
381            palette.fg
382        }),
383    );
384    sheet.define(style_ids::TEXT_MUTED, Style::new().fg(palette.hint));
385    sheet.define(
386        style_ids::TEXT_DISABLED,
387        Style::new().fg(if is_dark {
388            legacy::colors::TEXT_DISABLED
389        } else {
390            PackedRgba::rgb(180, 180, 190)
391        }),
392    );
393
394    // Accents
395    sheet.define(
396        style_ids::ACCENT_PRIMARY,
397        Style::new().fg(palette.accent).bold(),
398    );
399    sheet.define(
400        style_ids::ACCENT_SECONDARY,
401        Style::new().fg(palette.accent_alt),
402    );
403    sheet.define(
404        style_ids::ACCENT_TERTIARY,
405        Style::new().fg(if is_dark {
406            legacy::colors::ACCENT_TERTIARY
407        } else {
408            PackedRgba::rgb(0, 130, 200)
409        }),
410    );
411
412    // Surfaces
413    sheet.define(style_ids::BG_DEEP, Style::new().bg(palette.bg));
414    sheet.define(style_ids::BG_SURFACE, Style::new().bg(palette.surface));
415    sheet.define(
416        style_ids::BG_HIGHLIGHT,
417        Style::new().bg(if is_dark {
418            legacy::colors::BG_HIGHLIGHT
419        } else {
420            PackedRgba::rgb(230, 232, 240)
421        }),
422    );
423
424    // Borders
425    sheet.define(style_ids::BORDER, Style::new().fg(palette.border));
426    sheet.define(
427        style_ids::BORDER_FOCUS,
428        Style::new().fg(legacy::colors::BORDER_FOCUS),
429    );
430    sheet.define(
431        style_ids::BORDER_MINIMAL,
432        Style::new().fg(legacy::colors::BORDER_MINIMAL),
433    );
434    sheet.define(
435        style_ids::BORDER_EMPHASIZED,
436        Style::new().fg(legacy::colors::BORDER_EMPHASIZED),
437    );
438
439    // Roles (foreground)
440    sheet.define(style_ids::ROLE_USER, Style::new().fg(palette.user));
441    sheet.define(style_ids::ROLE_AGENT, Style::new().fg(palette.agent));
442    sheet.define(style_ids::ROLE_TOOL, Style::new().fg(palette.tool));
443    sheet.define(style_ids::ROLE_SYSTEM, Style::new().fg(palette.system));
444
445    // Role backgrounds
446    sheet.define(
447        style_ids::ROLE_USER_BG,
448        Style::new().bg(legacy::colors::ROLE_USER_BG),
449    );
450    sheet.define(
451        style_ids::ROLE_AGENT_BG,
452        Style::new().bg(legacy::colors::ROLE_AGENT_BG),
453    );
454    sheet.define(
455        style_ids::ROLE_TOOL_BG,
456        Style::new().bg(legacy::colors::ROLE_TOOL_BG),
457    );
458    sheet.define(
459        style_ids::ROLE_SYSTEM_BG,
460        Style::new().bg(legacy::colors::ROLE_SYSTEM_BG),
461    );
462
463    // Status
464    sheet.define(
465        style_ids::STATUS_SUCCESS,
466        Style::new().fg(legacy::colors::STATUS_SUCCESS),
467    );
468    sheet.define(
469        style_ids::STATUS_WARNING,
470        Style::new().fg(legacy::colors::STATUS_WARNING),
471    );
472    sheet.define(
473        style_ids::STATUS_ERROR,
474        Style::new().fg(legacy::colors::STATUS_ERROR).bold(),
475    );
476    sheet.define(
477        style_ids::STATUS_INFO,
478        Style::new().fg(legacy::colors::STATUS_INFO),
479    );
480
481    // Interaction states
482    sheet.define(
483        style_ids::HIGHLIGHT,
484        Style::new().fg(palette.bg).bg(palette.accent).bold(),
485    );
486    sheet.define(
487        style_ids::SELECTED,
488        Style::new()
489            .bg(if is_dark {
490                legacy::colors::BG_HIGHLIGHT
491            } else {
492                PackedRgba::rgb(220, 224, 236)
493            })
494            .bold(),
495    );
496    sheet.define(style_ids::CHIP, Style::new().fg(palette.accent_alt).bold());
497    sheet.define(style_ids::KBD, Style::new().fg(palette.accent).bold());
498    sheet.define(
499        style_ids::CODE,
500        Style::new()
501            .fg(if is_dark {
502                legacy::colors::TEXT_SECONDARY
503            } else {
504                palette.fg
505            })
506            .bg(palette.surface),
507    );
508
509    // Zebra stripes
510    sheet.define(style_ids::STRIPE_EVEN, Style::new().bg(palette.stripe_even));
511    sheet.define(style_ids::STRIPE_ODD, Style::new().bg(palette.stripe_odd));
512
513    // Gradients (only meaningful for dark presets with truecolor)
514    if !flags.no_gradient {
515        sheet.define(
516            style_ids::GRADIENT_TOP,
517            Style::new().bg(legacy::colors::GRADIENT_HEADER_TOP),
518        );
519        sheet.define(
520            style_ids::GRADIENT_MID,
521            Style::new().bg(legacy::colors::GRADIENT_HEADER_MID),
522        );
523        sheet.define(
524            style_ids::GRADIENT_BOT,
525            Style::new().bg(legacy::colors::GRADIENT_HEADER_BOT),
526        );
527    }
528
529    sheet
530}
531
532// ─── Helpers ─────────────────────────────────────────────────────────────────
533
534/// Check if an env var is set and truthy (non-empty, not "0"/"false"/"off"/"no").
535fn env_truthy(name: &str) -> bool {
536    match dotenvy::var(name) {
537        Ok(val) => {
538            let normalized = val.trim().to_ascii_lowercase();
539            !normalized.is_empty()
540                && normalized != "0"
541                && normalized != "false"
542                && normalized != "off"
543                && normalized != "no"
544        }
545        Err(_) => false,
546    }
547}
548
549/// Check if an env var equals a specific value.
550fn env_is(name: &str, expected: &str) -> bool {
551    dotenvy::var(name).map(|v| v == expected).unwrap_or(false)
552}
553
554// ─── Color interpolation (migrated from tui.rs) ─────────────────────────────
555
556/// Linear interpolation between two u8 values.
557pub fn lerp_u8(a: u8, b: u8, t: f32) -> u8 {
558    let t = t.clamp(0.0, 1.0);
559    let result = f32::from(a) * (1.0 - t) + f32::from(b) * t;
560    result.round() as u8
561}
562
563/// Smoothly interpolate between two ftui Colors.
564///
565/// Only works with RGB colors; non-RGB falls back to a binary switch at 50%.
566pub fn lerp_color(from: Color, to: Color, progress: f32) -> Color {
567    let from_rgb = from.to_rgb();
568    let to_rgb = to.to_rgb();
569    Color::rgb(
570        lerp_u8(from_rgb.r, to_rgb.r, progress),
571        lerp_u8(from_rgb.g, to_rgb.g, progress),
572        lerp_u8(from_rgb.b, to_rgb.b, progress),
573    )
574}
575
576/// Dim a color by multiplying its RGB channels by `factor` (0.0=black, 1.0=original).
577pub fn dim_color(color: Color, factor: f32) -> Color {
578    let rgb = color.to_rgb();
579    let factor = factor.clamp(0.0, 1.0);
580    Color::rgb(
581        (f32::from(rgb.r) * factor).round() as u8,
582        (f32::from(rgb.g) * factor).round() as u8,
583        (f32::from(rgb.b) * factor).round() as u8,
584    )
585}
586
587// ─── Tests ───────────────────────────────────────────────────────────────────
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn default_creates_dark_theme() {
595        let theme = CassTheme::default();
596        assert_eq!(theme.preset, ThemePreset::TokyoNight);
597        assert!(theme.is_dark);
598    }
599
600    #[test]
601    fn all_presets_build_without_panic() {
602        let flags = ThemeFlags::all_enabled();
603        for preset in ThemePreset::all() {
604            let _ = CassTheme::with_options(*preset, ColorProfile::TrueColor, flags);
605        }
606    }
607
608    #[test]
609    fn style_sheet_has_core_styles() {
610        let theme = CassTheme::with_options(
611            ThemePreset::TokyoNight,
612            ColorProfile::TrueColor,
613            ThemeFlags::all_enabled(),
614        );
615        // Verify key styles are populated
616        assert!(theme.styles.contains(style_ids::TEXT_PRIMARY));
617        assert!(theme.styles.contains(style_ids::ROLE_USER));
618        assert!(theme.styles.contains(style_ids::ROLE_AGENT));
619        assert!(theme.styles.contains(style_ids::BORDER));
620        assert!(theme.styles.contains(style_ids::HIGHLIGHT));
621        assert!(theme.styles.contains(style_ids::STRIPE_EVEN));
622        assert!(theme.styles.contains(style_ids::STRIPE_ODD));
623        assert!(theme.styles.contains(style_ids::STATUS_ERROR));
624    }
625
626    #[test]
627    fn preset_cycling_wraps() {
628        let mut theme = CassTheme::with_options(
629            ThemePreset::Colorblind,
630            ColorProfile::TrueColor,
631            ThemeFlags::all_enabled(),
632        );
633        theme.next_preset();
634        assert_eq!(theme.preset, ThemePreset::TokyoNight);
635    }
636
637    #[test]
638    fn no_color_forces_mono_profile() {
639        let flags = ThemeFlags::custom(true, false, false, false, false);
640        let theme =
641            CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
642        // Even if we pass TrueColor, the theme stores it as-is (profile is up to
643        // the caller when using with_options), but from_preset would force Mono.
644        assert!(theme.flags.no_color);
645    }
646
647    #[test]
648    fn no_icons_suppresses_agent_icons() {
649        let flags = ThemeFlags::custom(false, true, false, false, false);
650        let theme =
651            CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
652        assert_eq!(theme.agent_icon("codex"), "");
653        assert_eq!(theme.agent_icon("claude_code"), "");
654    }
655
656    #[test]
657    fn icons_shown_by_default() {
658        let flags = ThemeFlags::all_enabled();
659        let theme =
660            CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
661        assert_eq!(theme.agent_icon("codex"), "\u{25c6}"); // ◆
662    }
663
664    #[test]
665    fn role_styles_return_non_default() {
666        let theme = CassTheme::with_options(
667            ThemePreset::TokyoNight,
668            ColorProfile::TrueColor,
669            ThemeFlags::all_enabled(),
670        );
671        let user_style = theme.role_style("user");
672        let agent_style = theme.role_style("assistant");
673        let tool_style = theme.role_style("tool");
674        let system_style = theme.role_style("system");
675        // Each should have a foreground set
676        assert!(!user_style.is_empty());
677        assert!(!agent_style.is_empty());
678        assert!(!tool_style.is_empty());
679        assert!(!system_style.is_empty());
680    }
681
682    #[test]
683    fn stripe_alternates() {
684        let theme = CassTheme::with_options(
685            ThemePreset::TokyoNight,
686            ColorProfile::TrueColor,
687            ThemeFlags::all_enabled(),
688        );
689        let even = theme.stripe_style(0);
690        let odd = theme.stripe_style(1);
691        // They should be different for dark theme
692        assert_ne!(even, odd);
693    }
694
695    #[test]
696    fn light_theme_has_light_bg() {
697        let theme = CassTheme::with_options(
698            ThemePreset::Daylight,
699            ColorProfile::TrueColor,
700            ThemeFlags::all_enabled(),
701        );
702        assert!(!theme.is_dark);
703    }
704
705    #[test]
706    fn high_contrast_has_core_styles() {
707        let theme = CassTheme::with_options(
708            ThemePreset::HighContrast,
709            ColorProfile::TrueColor,
710            ThemeFlags::all_enabled(),
711        );
712        assert!(theme.styles.contains(style_ids::ROLE_USER));
713        assert!(theme.styles.contains(style_ids::STATUS_ERROR));
714    }
715
716    #[test]
717    fn compose_merges_styles() {
718        let theme = CassTheme::with_options(
719            ThemePreset::TokyoNight,
720            ColorProfile::TrueColor,
721            ThemeFlags::all_enabled(),
722        );
723        let composed = theme.compose(&[style_ids::BG_DEEP, style_ids::TEXT_PRIMARY]);
724        // Should have both bg and fg set
725        assert!(!composed.is_empty());
726    }
727
728    // Color interpolation tests
729
730    #[test]
731    fn lerp_u8_extremes() {
732        assert_eq!(lerp_u8(0, 255, 0.0), 0);
733        assert_eq!(lerp_u8(0, 255, 1.0), 255);
734        assert_eq!(lerp_u8(0, 200, 0.5), 100);
735    }
736
737    #[test]
738    fn lerp_u8_clamps() {
739        assert_eq!(lerp_u8(0, 100, -1.0), 0);
740        assert_eq!(lerp_u8(0, 100, 2.0), 100);
741    }
742
743    #[test]
744    fn lerp_color_identity() {
745        let c = Color::rgb(100, 150, 200);
746        let result = lerp_color(c, c, 0.5);
747        assert_eq!(result, c);
748    }
749
750    #[test]
751    fn lerp_color_midpoint() {
752        let from = Color::rgb(0, 0, 0);
753        let to = Color::rgb(200, 100, 50);
754        let mid = lerp_color(from, to, 0.5);
755        let rgb = mid.to_rgb();
756        assert_eq!(rgb.r, 100);
757        assert_eq!(rgb.g, 50);
758        assert_eq!(rgb.b, 25);
759    }
760
761    #[test]
762    fn dim_color_half() {
763        let c = Color::rgb(200, 100, 50);
764        let dimmed = dim_color(c, 0.5);
765        let rgb = dimmed.to_rgb();
766        assert_eq!(rgb.r, 100);
767        assert_eq!(rgb.g, 50);
768        assert_eq!(rgb.b, 25);
769    }
770
771    #[test]
772    fn dim_color_zero_is_black() {
773        let c = Color::rgb(200, 100, 50);
774        let dimmed = dim_color(c, 0.0);
775        let rgb = dimmed.to_rgb();
776        assert_eq!(rgb.r, 0);
777        assert_eq!(rgb.g, 0);
778        assert_eq!(rgb.b, 0);
779    }
780
781    #[test]
782    fn packed_rgba_to_color_round_trips() {
783        let orig = PackedRgba::rgb(42, 84, 168);
784        let ftui_color: Color = orig.into();
785        let rgb = ftui_color.to_rgb();
786        assert_eq!(rgb.r, 42);
787        assert_eq!(rgb.g, 84);
788        assert_eq!(rgb.b, 168);
789    }
790
791    #[test]
792    fn no_gradient_skips_gradient_styles() {
793        let flags = ThemeFlags::custom(false, false, true, false, false);
794        let theme =
795            CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
796        assert!(!theme.styles.contains(style_ids::GRADIENT_TOP));
797        assert!(!theme.show_gradient());
798    }
799
800    #[test]
801    fn gradient_present_when_enabled() {
802        let flags = ThemeFlags::all_enabled();
803        let theme =
804            CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
805        assert!(theme.styles.contains(style_ids::GRADIENT_TOP));
806        assert!(theme.styles.contains(style_ids::GRADIENT_MID));
807        assert!(theme.styles.contains(style_ids::GRADIENT_BOT));
808    }
809
810    #[test]
811    fn a11y_mode_reports_correctly() {
812        let flags = ThemeFlags::custom(false, false, false, false, true);
813        let theme =
814            CassTheme::with_options(ThemePreset::TokyoNight, ColorProfile::TrueColor, flags);
815        assert!(theme.a11y_mode());
816    }
817
818    #[test]
819    fn theme_flags_default_all_enabled() {
820        let flags = ThemeFlags::default();
821        assert!(!flags.no_color);
822        assert!(!flags.no_icons);
823        assert!(!flags.no_gradient);
824        assert!(!flags.no_animations);
825        assert!(!flags.a11y);
826    }
827
828    #[test]
829    fn legacy_palette_matches_preset() {
830        let theme = CassTheme::with_options(
831            ThemePreset::Nord,
832            ColorProfile::TrueColor,
833            ThemeFlags::all_enabled(),
834        );
835        let palette = theme.legacy_palette();
836        assert_eq!(palette.bg, ThemePalette::nord().bg);
837    }
838}