Skip to main content

fresh/view/theme/
types.rs

1//! Pure theme types without I/O operations.
2//!
3//! This module contains all theme-related data structures that can be used
4//! without filesystem access. This enables WASM compatibility and easier testing.
5
6use ratatui::style::{Color, Modifier};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10pub const THEME_DARK: &str = "dark";
11pub const THEME_LIGHT: &str = "light";
12pub const THEME_HIGH_CONTRAST: &str = "high-contrast";
13pub const THEME_NOSTALGIA: &str = "nostalgia";
14pub const THEME_DRACULA: &str = "dracula";
15pub const THEME_NORD: &str = "nord";
16pub const THEME_SOLARIZED_DARK: &str = "solarized-dark";
17/// Theme that defers to the host terminal's palette and background
18/// (uses `Default` and named ANSI colors for everything visual), so
19/// fresh inherits whatever colorscheme the terminal already has.
20pub const THEME_TERMINAL: &str = "terminal";
21
22/// A builtin theme with its name, pack, and embedded JSON content.
23pub struct BuiltinTheme {
24    pub name: &'static str,
25    /// Pack name (subdirectory path, empty for root themes)
26    pub pack: &'static str,
27    pub json: &'static str,
28}
29
30// Include the auto-generated BUILTIN_THEMES array from build.rs
31include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs"));
32
33/// Information about an available theme.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ThemeInfo {
36    /// Theme display name (e.g., "dark", "adwaita-dark")
37    pub name: String,
38    /// Pack name (subdirectory path, empty for root themes)
39    pub pack: String,
40    /// Unique key used as the registry identifier.
41    ///
42    /// Derivation priority:
43    /// 1. Package themes: `{repository_url}#{theme_name}`
44    /// 2. User-saved themes (theme editor): `file://{absolute_path}`
45    /// 3. Loose user themes: `{pack}/{name}` or just `{name}` if pack is empty
46    /// 4. Builtins: just the name
47    pub key: String,
48}
49
50impl ThemeInfo {
51    /// Create a new ThemeInfo. The key defaults to `pack/name` (or just `name`
52    /// when pack is empty).
53    pub fn new(name: impl Into<String>, pack: impl Into<String>) -> Self {
54        let name = name.into();
55        let pack = pack.into();
56        let key = if pack.is_empty() {
57            name.clone()
58        } else {
59            format!("{}/{}", pack, name)
60        };
61        Self { name, pack, key }
62    }
63
64    /// Create a ThemeInfo with an explicit key (e.g. a repository URL).
65    pub fn with_key(
66        name: impl Into<String>,
67        pack: impl Into<String>,
68        key: impl Into<String>,
69    ) -> Self {
70        Self {
71            name: name.into(),
72            pack: pack.into(),
73            key: key.into(),
74        }
75    }
76
77    /// Get display name showing pack if present
78    pub fn display_name(&self) -> String {
79        if self.pack.is_empty() {
80            self.name.clone()
81        } else {
82            format!("{} ({})", self.name, self.pack)
83        }
84    }
85}
86
87/// Convert a ratatui Color to RGB values.
88/// Returns None for Reset or Indexed colors.
89pub fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
90    match color {
91        Color::Rgb(r, g, b) => Some((r, g, b)),
92        Color::White => Some((255, 255, 255)),
93        Color::Black => Some((0, 0, 0)),
94        Color::Red => Some((205, 0, 0)),
95        Color::Green => Some((0, 205, 0)),
96        Color::Blue => Some((0, 0, 238)),
97        Color::Yellow => Some((205, 205, 0)),
98        Color::Magenta => Some((205, 0, 205)),
99        Color::Cyan => Some((0, 205, 205)),
100        Color::Gray => Some((229, 229, 229)),
101        Color::DarkGray => Some((127, 127, 127)),
102        Color::LightRed => Some((255, 0, 0)),
103        Color::LightGreen => Some((0, 255, 0)),
104        Color::LightBlue => Some((92, 92, 255)),
105        Color::LightYellow => Some((255, 255, 0)),
106        Color::LightMagenta => Some((255, 0, 255)),
107        Color::LightCyan => Some((0, 255, 255)),
108        Color::Reset | Color::Indexed(_) => None,
109    }
110}
111
112/// Brighten a color by adding an amount to each RGB component.
113/// Clamps values to 255.
114pub fn brighten_color(color: Color, amount: u8) -> Color {
115    if let Some((r, g, b)) = color_to_rgb(color) {
116        Color::Rgb(
117            r.saturating_add(amount),
118            g.saturating_add(amount),
119            b.saturating_add(amount),
120        )
121    } else {
122        color
123    }
124}
125
126/// Shift an RGB color a small amount toward the opposite end of the
127/// brightness spectrum: dark colors become slightly brighter, light colors
128/// slightly darker. Non-RGB colors are returned unchanged.
129///
130/// Used to derive subtle visual cues (e.g. post-EOF background shade) from
131/// a theme's editor background without requiring theme authors to pick an
132/// explicit color.
133pub fn shade_toward_contrast(color: Color, amount: u8) -> Color {
134    if let Some((r, g, b)) = color_to_rgb(color) {
135        let avg = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
136        if avg < 128 {
137            Color::Rgb(
138                r.saturating_add(amount),
139                g.saturating_add(amount),
140                b.saturating_add(amount),
141            )
142        } else {
143            Color::Rgb(
144                r.saturating_sub(amount),
145                g.saturating_sub(amount),
146                b.saturating_sub(amount),
147            )
148        }
149    } else {
150        color
151    }
152}
153
154/// Serializable color representation
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
156#[serde(untagged)]
157pub enum ColorDef {
158    /// RGB color as [r, g, b]
159    Rgb(u8, u8, u8),
160    /// Named color
161    Named(String),
162}
163
164impl From<ColorDef> for Color {
165    fn from(def: ColorDef) -> Self {
166        match def {
167            ColorDef::Rgb(r, g, b) => Color::Rgb(r, g, b),
168            ColorDef::Named(name) => match name.as_str() {
169                "Black" => Color::Black,
170                "Red" => Color::Red,
171                "Green" => Color::Green,
172                "Yellow" => Color::Yellow,
173                "Blue" => Color::Blue,
174                "Magenta" => Color::Magenta,
175                "Cyan" => Color::Cyan,
176                "Gray" => Color::Gray,
177                "DarkGray" => Color::DarkGray,
178                "LightRed" => Color::LightRed,
179                "LightGreen" => Color::LightGreen,
180                "LightYellow" => Color::LightYellow,
181                "LightBlue" => Color::LightBlue,
182                "LightMagenta" => Color::LightMagenta,
183                "LightCyan" => Color::LightCyan,
184                "White" => Color::White,
185                // Default/Reset uses the terminal's default color (preserves transparency)
186                "Default" | "Reset" => Color::Reset,
187                _ => Color::White, // Default fallback
188            },
189        }
190    }
191}
192
193/// Serializable text-attribute modifier list.
194///
195/// Lets a theme specify SGR text attributes (reverse video, bold,
196/// italic, underline, dim) on top of fg/bg colors. Designed for
197/// terminal-adaptive themes that want to use `["reversed"]` on the
198/// visual selection — the canonical pattern documented for native-
199/// palette themes (vim/neovim Visual mode, helix term16, htop, less)
200/// because reverse video automatically inverts the terminal's
201/// current fg/bg and so adapts to both light and dark backgrounds
202/// without a separate variant.
203///
204/// JSON form: `["reversed"]` or `["bold", "underlined"]`. Unknown
205/// strings are silently dropped so a typo can't crash a render.
206#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
207#[serde(transparent)]
208pub struct ModifierDef(pub Vec<String>);
209
210impl From<&ModifierDef> for Modifier {
211    fn from(def: &ModifierDef) -> Self {
212        let mut m = Modifier::empty();
213        for s in &def.0 {
214            match s.as_str() {
215                "reversed" | "reverse" => m |= Modifier::REVERSED,
216                "bold" => m |= Modifier::BOLD,
217                "italic" => m |= Modifier::ITALIC,
218                "underlined" | "underline" => m |= Modifier::UNDERLINED,
219                "dim" => m |= Modifier::DIM,
220                _ => {}
221            }
222        }
223        m
224    }
225}
226
227impl From<ModifierDef> for Modifier {
228    fn from(def: ModifierDef) -> Self {
229        Modifier::from(&def)
230    }
231}
232
233impl From<Modifier> for ModifierDef {
234    fn from(m: Modifier) -> Self {
235        // Order matches the canonical order in the parser, so a
236        // round-trip Theme -> ThemeFile -> Theme yields the same set.
237        let mut out = Vec::new();
238        if m.contains(Modifier::REVERSED) {
239            out.push("reversed".to_string());
240        }
241        if m.contains(Modifier::BOLD) {
242            out.push("bold".to_string());
243        }
244        if m.contains(Modifier::ITALIC) {
245            out.push("italic".to_string());
246        }
247        if m.contains(Modifier::UNDERLINED) {
248            out.push("underlined".to_string());
249        }
250        if m.contains(Modifier::DIM) {
251            out.push("dim".to_string());
252        }
253        ModifierDef(out)
254    }
255}
256
257/// Convert a named color string (e.g. "Yellow", "Red") to a ratatui Color.
258/// Returns None if the string is not a recognized named color.
259pub fn named_color_from_str(name: &str) -> Option<Color> {
260    match name {
261        "Black" => Some(Color::Black),
262        "Red" => Some(Color::Red),
263        "Green" => Some(Color::Green),
264        "Yellow" => Some(Color::Yellow),
265        "Blue" => Some(Color::Blue),
266        "Magenta" => Some(Color::Magenta),
267        "Cyan" => Some(Color::Cyan),
268        "Gray" => Some(Color::Gray),
269        "DarkGray" => Some(Color::DarkGray),
270        "LightRed" => Some(Color::LightRed),
271        "LightGreen" => Some(Color::LightGreen),
272        "LightYellow" => Some(Color::LightYellow),
273        "LightBlue" => Some(Color::LightBlue),
274        "LightMagenta" => Some(Color::LightMagenta),
275        "LightCyan" => Some(Color::LightCyan),
276        "White" => Some(Color::White),
277        "Default" | "Reset" => Some(Color::Reset),
278        _ => None,
279    }
280}
281
282/// Convert a ratatui `Color` into the lossless `TokenColor::Named`
283/// string form used by `ViewTokenStyle` (for everything except
284/// `Color::Rgb`, which uses the array variant). The corresponding
285/// inverse lives on [`TokenColorExt::to_ratatui`].
286fn token_color_named_from_ratatui(color: Color) -> &'static str {
287    match color {
288        Color::Black => "Black",
289        Color::Red => "Red",
290        Color::Green => "Green",
291        Color::Yellow => "Yellow",
292        Color::Blue => "Blue",
293        Color::Magenta => "Magenta",
294        Color::Cyan => "Cyan",
295        Color::Gray => "Gray",
296        Color::DarkGray => "DarkGray",
297        Color::LightRed => "LightRed",
298        Color::LightGreen => "LightGreen",
299        Color::LightYellow => "LightYellow",
300        Color::LightBlue => "LightBlue",
301        Color::LightMagenta => "LightMagenta",
302        Color::LightCyan => "LightCyan",
303        Color::White => "White",
304        Color::Reset => "Default",
305        // Rgb and Indexed are handled by callers; this fn is for the
306        // named-only set above.
307        _ => "Default",
308    }
309}
310
311/// Resolve a `TokenColor` (the lossless RGB-or-named color carried by
312/// `ViewTokenStyle`) and produce a ratatui `Color` ready for the
313/// renderer. Named strings try (in order) an ANSI name, then
314/// `"Indexed:N"` for 256-color values, then a theme-key lookup
315/// against `theme`. Unknown strings fall through to `Color::Reset`
316/// so a typo in a plugin can't make text disappear.
317pub trait TokenColorExt {
318    fn to_ratatui(&self, theme: &Theme) -> Color;
319    fn from_ratatui(color: Color) -> Option<fresh_core::api::TokenColor>;
320}
321
322impl TokenColorExt for fresh_core::api::TokenColor {
323    fn to_ratatui(&self, theme: &Theme) -> Color {
324        use fresh_core::api::TokenColor;
325        match self {
326            TokenColor::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
327            TokenColor::Named(name) => {
328                if let Some(c) = named_color_from_str(name) {
329                    return c;
330                }
331                if let Some(rest) = name.strip_prefix("Indexed:") {
332                    if let Ok(n) = rest.parse::<u8>() {
333                        return Color::Indexed(n);
334                    }
335                }
336                theme.resolve_theme_key(name).unwrap_or(Color::Reset)
337            }
338        }
339    }
340
341    fn from_ratatui(color: Color) -> Option<fresh_core::api::TokenColor> {
342        use fresh_core::api::TokenColor;
343        match color {
344            Color::Rgb(r, g, b) => Some(TokenColor::Rgb(r, g, b)),
345            Color::Indexed(n) => Some(TokenColor::Named(format!("Indexed:{n}"))),
346            other => Some(TokenColor::Named(
347                token_color_named_from_ratatui(other).to_string(),
348            )),
349        }
350    }
351}
352
353impl From<Color> for ColorDef {
354    fn from(color: Color) -> Self {
355        match color {
356            Color::Rgb(r, g, b) => ColorDef::Rgb(r, g, b),
357            Color::White => ColorDef::Named("White".to_string()),
358            Color::Black => ColorDef::Named("Black".to_string()),
359            Color::Red => ColorDef::Named("Red".to_string()),
360            Color::Green => ColorDef::Named("Green".to_string()),
361            Color::Blue => ColorDef::Named("Blue".to_string()),
362            Color::Yellow => ColorDef::Named("Yellow".to_string()),
363            Color::Magenta => ColorDef::Named("Magenta".to_string()),
364            Color::Cyan => ColorDef::Named("Cyan".to_string()),
365            Color::Gray => ColorDef::Named("Gray".to_string()),
366            Color::DarkGray => ColorDef::Named("DarkGray".to_string()),
367            Color::LightRed => ColorDef::Named("LightRed".to_string()),
368            Color::LightGreen => ColorDef::Named("LightGreen".to_string()),
369            Color::LightBlue => ColorDef::Named("LightBlue".to_string()),
370            Color::LightYellow => ColorDef::Named("LightYellow".to_string()),
371            Color::LightMagenta => ColorDef::Named("LightMagenta".to_string()),
372            Color::LightCyan => ColorDef::Named("LightCyan".to_string()),
373            Color::Reset => ColorDef::Named("Default".to_string()),
374            Color::Indexed(_) => {
375                // Fallback for indexed colors
376                if let Some((r, g, b)) = color_to_rgb(color) {
377                    ColorDef::Rgb(r, g, b)
378                } else {
379                    ColorDef::Named("Default".to_string())
380                }
381            }
382        }
383    }
384}
385
386/// Serializable theme definition (matches JSON structure)
387///
388/// The five color sections (`editor`, `ui`, `search`, `diagnostic`, `syntax`)
389/// are all optional. Every leaf field within each section already has a
390/// `#[serde(default = "…")]` fallback, so a theme JSON only needs to specify
391/// the colors it cares about. This matches the minimal example shipped in
392/// `docs/features/themes.md` and unblocks user-authored themes that override
393/// just `editor`/`syntax` (issue #1281).
394///
395/// **Inheritance**: when a theme omits whole sections, the unset fields are
396/// resolved against a *base* theme rather than against the per-field hardcoded
397/// fallback. The base is chosen in this order:
398///
399/// 1. Explicit `extends` field (`"builtin://light"`, `"dark"`, etc.).
400/// 2. If `editor.bg` is provided, the relative-luminance of that color picks
401///    `builtin://light` or `builtin://dark` automatically — so a user theme
402///    that sets a cream background gets light UI chrome without any extra
403///    configuration.
404/// 3. Otherwise, fall through to the per-field hardcoded defaults.
405///
406/// Only built-in themes are valid `extends` targets in this version. Chained
407/// inheritance across user themes is intentionally out of scope here.
408#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
409pub struct ThemeFile {
410    /// Theme name
411    pub name: String,
412    /// Optional base theme to inherit from. Accepts `"builtin://NAME"` or a
413    /// bare built-in name (e.g. `"dark"`, `"light"`, `"high-contrast"`).
414    /// When set, every field this theme does not specify is taken from the
415    /// base; explicit fields override the base. See [`ThemeFile`] for the
416    /// full inheritance resolution order.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub extends: Option<String>,
419    /// Editor area colors
420    #[serde(default = "default_editor_colors")]
421    pub editor: EditorColors,
422    /// UI element colors (tabs, menus, status bar, etc.)
423    #[serde(default = "default_ui_colors")]
424    pub ui: UiColors,
425    /// Search result highlighting colors
426    #[serde(default = "default_search_colors")]
427    pub search: SearchColors,
428    /// LSP diagnostic colors (errors, warnings, etc.)
429    #[serde(default = "default_diagnostic_colors")]
430    pub diagnostic: DiagnosticColors,
431    /// Syntax highlighting colors
432    #[serde(default = "default_syntax_colors")]
433    pub syntax: SyntaxColors,
434}
435
436// Per-section defaults piggyback on the field-level `#[serde(default = "…")]`
437// already declared on every leaf — deserializing an empty object materializes
438// an all-defaults section without us having to restate every field here, and
439// keeps the section default in lock-step with its field defaults.
440fn default_section<T: serde::de::DeserializeOwned>(section: &'static str) -> T {
441    serde_json::from_str("{}").unwrap_or_else(|e| {
442        panic!(
443            "theme section `{}` must be default-constructible from `{{}}` \
444             (every field needs `#[serde(default = ...)]`): {}",
445            section, e
446        )
447    })
448}
449
450fn default_editor_colors() -> EditorColors {
451    default_section("editor")
452}
453
454fn default_ui_colors() -> UiColors {
455    default_section("ui")
456}
457
458fn default_search_colors() -> SearchColors {
459    default_section("search")
460}
461
462fn default_diagnostic_colors() -> DiagnosticColors {
463    default_section("diagnostic")
464}
465
466fn default_syntax_colors() -> SyntaxColors {
467    default_section("syntax")
468}
469
470/// Editor area colors
471#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
472pub struct EditorColors {
473    /// Editor background color
474    #[serde(default = "default_editor_bg")]
475    pub bg: ColorDef,
476    /// Default text color
477    #[serde(default = "default_editor_fg")]
478    pub fg: ColorDef,
479    /// Cursor color
480    #[serde(default = "default_cursor")]
481    pub cursor: ColorDef,
482    /// Cursor color in unfocused splits
483    #[serde(default = "default_inactive_cursor")]
484    pub inactive_cursor: ColorDef,
485    /// Selected text background
486    #[serde(default = "default_selection_bg")]
487    pub selection_bg: ColorDef,
488    /// Optional text-attribute modifiers (e.g. `["reversed"]`) layered
489    /// on top of `selection_bg`. Themes that want a terminal-adaptive
490    /// visual selection (the canonical pattern for native-palette
491    /// themes — vim/neovim Visual, helix term16, htop, less) set
492    /// `["reversed"]` here; the renderer ORs `Modifier::REVERSED` into
493    /// the selected cells, which works on any terminal because it
494    /// inverts whatever fg/bg the terminal already uses.
495    #[serde(default)]
496    pub selection_modifier: Option<ModifierDef>,
497    /// Background of the line containing cursor
498    #[serde(default = "default_current_line_bg")]
499    pub current_line_bg: ColorDef,
500    /// Line number text color
501    #[serde(default = "default_line_number_fg")]
502    pub line_number_fg: ColorDef,
503    /// Line number gutter background
504    #[serde(default = "default_line_number_bg")]
505    pub line_number_bg: ColorDef,
506    /// Diff added line background
507    #[serde(default = "default_diff_add_bg")]
508    pub diff_add_bg: ColorDef,
509    /// Diff removed line background
510    #[serde(default = "default_diff_remove_bg")]
511    pub diff_remove_bg: ColorDef,
512    /// Diff added word-level highlight background (optional override)
513    /// When not set, computed by brightening diff_add_bg
514    #[serde(default)]
515    pub diff_add_highlight_bg: Option<ColorDef>,
516    /// Diff removed word-level highlight background (optional override)
517    /// When not set, computed by brightening diff_remove_bg
518    #[serde(default)]
519    pub diff_remove_highlight_bg: Option<ColorDef>,
520    /// Diff modified line background
521    #[serde(default = "default_diff_modify_bg")]
522    pub diff_modify_bg: ColorDef,
523    /// Fallback fg for cells whose existing fg matches `diff_add_bg`
524    /// (e.g. ANSI Green-on-Green). Only applied on collision; other
525    /// tokens keep their syntax colour.
526    #[serde(default)]
527    pub diff_add_collision_fg: Option<ColorDef>,
528    /// Collision-only fallback fg for `diff_remove_bg`.
529    #[serde(default)]
530    pub diff_remove_collision_fg: Option<ColorDef>,
531    /// Collision-only fallback fg for `diff_modify_bg`.
532    #[serde(default)]
533    pub diff_modify_collision_fg: Option<ColorDef>,
534    /// Vertical ruler background color
535    #[serde(default = "default_ruler_bg")]
536    pub ruler_bg: ColorDef,
537    /// Indentation guide foreground color. When omitted, inherits
538    /// `whitespace_indicator_fg` so guides remain subtle in both dark and
539    /// light themes while still allowing a dedicated override.
540    #[serde(default)]
541    pub indentation_guide_fg: Option<ColorDef>,
542    /// Whitespace indicator foreground color (for tab arrows and space dots)
543    #[serde(default = "default_whitespace_indicator_fg")]
544    pub whitespace_indicator_fg: ColorDef,
545    /// Bracket match highlight color (used when rainbow is disabled)
546    #[serde(default = "default_bracket_match_fg")]
547    pub bracket_match_fg: ColorDef,
548    /// Rainbow bracket color (nesting level 1)
549    #[serde(default = "default_bracket_rainbow_1")]
550    pub bracket_rainbow_1: ColorDef,
551    /// Rainbow bracket color (nesting level 2)
552    #[serde(default = "default_bracket_rainbow_2")]
553    pub bracket_rainbow_2: ColorDef,
554    /// Rainbow bracket color (nesting level 3)
555    #[serde(default = "default_bracket_rainbow_3")]
556    pub bracket_rainbow_3: ColorDef,
557    /// Rainbow bracket color (nesting level 4)
558    #[serde(default = "default_bracket_rainbow_4")]
559    pub bracket_rainbow_4: ColorDef,
560    /// Rainbow bracket color (nesting level 5)
561    #[serde(default = "default_bracket_rainbow_5")]
562    pub bracket_rainbow_5: ColorDef,
563    /// Rainbow bracket color (nesting level 6)
564    #[serde(default = "default_bracket_rainbow_6")]
565    pub bracket_rainbow_6: ColorDef,
566    /// Background color for lines after end-of-file (optional override).
567    /// When not set, computed as a slightly contrasting shade of `bg`
568    /// (lighter for dark themes, darker for light themes) to give post-EOF
569    /// rows a subtle visual separation from the buffer content.
570    #[serde(default)]
571    pub after_eof_bg: Option<ColorDef>,
572}
573
574// Default editor colors (for minimal themes)
575fn default_editor_bg() -> ColorDef {
576    ColorDef::Rgb(30, 30, 30)
577}
578fn default_editor_fg() -> ColorDef {
579    ColorDef::Rgb(212, 212, 212)
580}
581fn default_cursor() -> ColorDef {
582    ColorDef::Rgb(255, 255, 255)
583}
584fn default_inactive_cursor() -> ColorDef {
585    ColorDef::Named("DarkGray".to_string())
586}
587fn default_selection_bg() -> ColorDef {
588    ColorDef::Rgb(38, 79, 120)
589}
590fn default_current_line_bg() -> ColorDef {
591    ColorDef::Rgb(40, 40, 40)
592}
593fn default_line_number_fg() -> ColorDef {
594    ColorDef::Rgb(100, 100, 100)
595}
596fn default_line_number_bg() -> ColorDef {
597    ColorDef::Rgb(30, 30, 30)
598}
599fn default_diff_add_bg() -> ColorDef {
600    ColorDef::Rgb(35, 60, 35) // Dark green
601}
602fn default_diff_remove_bg() -> ColorDef {
603    ColorDef::Rgb(70, 35, 35) // Dark red
604}
605fn default_diff_modify_bg() -> ColorDef {
606    ColorDef::Rgb(40, 38, 30) // Very subtle yellow tint, close to dark bg
607}
608fn default_ruler_bg() -> ColorDef {
609    ColorDef::Rgb(50, 50, 50) // Subtle dark gray, slightly lighter than default editor bg
610}
611fn default_whitespace_indicator_fg() -> ColorDef {
612    ColorDef::Rgb(70, 70, 70) // Subdued dark gray, subtle but visible
613}
614fn default_bracket_match_fg() -> ColorDef {
615    ColorDef::Rgb(255, 215, 0) // Gold
616}
617fn default_bracket_rainbow_1() -> ColorDef {
618    ColorDef::Rgb(255, 215, 0) // Gold
619}
620fn default_bracket_rainbow_2() -> ColorDef {
621    ColorDef::Rgb(218, 112, 214) // Orchid
622}
623fn default_bracket_rainbow_3() -> ColorDef {
624    ColorDef::Rgb(50, 205, 50) // Lime Green
625}
626fn default_bracket_rainbow_4() -> ColorDef {
627    ColorDef::Rgb(30, 144, 255) // Dodger Blue
628}
629fn default_bracket_rainbow_5() -> ColorDef {
630    ColorDef::Rgb(255, 127, 80) // Coral
631}
632fn default_bracket_rainbow_6() -> ColorDef {
633    ColorDef::Rgb(147, 112, 219) // Medium Purple
634}
635
636/// UI element colors (tabs, menus, status bar, etc.)
637///
638/// Naming convention: every `*_bg` key that has text drawn on top of
639/// it MUST have a matching `*_fg` key, and renderers must pair them
640/// (never borrow a foreground from an unrelated surface — doing so is
641/// how text ends up invisible when a theme's borrowed fg matches the
642/// real bg). `popup_text_fg` is the foreground for `popup_bg` (kept
643/// under its historical name with a `popup_fg` serde alias).
644///
645/// `*_bg` keys WITHOUT a matching `*_fg` are intentional — they don't
646/// draw their own text and inherit the surrounding foreground:
647///   - lines / borders / separators: `tab_separator_bg`,
648///     `popup_border_fg`, `menu_border_fg`, `menu_separator_fg`,
649///     `split_separator_fg`, `scrollbar_*`, `tab_drop_zone_border`
650///   - selection / hover / highlight tints layered over a surface that
651///     keeps its base fg: `*_selection_bg`, `*_hover_bg`,
652///     `suggestion_selected_bg`, `text_input_selection_bg`,
653///     `semantic_highlight_bg`, `compose_margin_bg`, `inline_code_bg`
654#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
655pub struct UiColors {
656    /// Active tab text color
657    #[serde(default = "default_tab_active_fg")]
658    pub tab_active_fg: ColorDef,
659    /// Active tab background color
660    #[serde(default = "default_tab_active_bg")]
661    pub tab_active_bg: ColorDef,
662    /// Inactive tab text color
663    #[serde(default = "default_tab_inactive_fg")]
664    pub tab_inactive_fg: ColorDef,
665    /// Inactive tab background color
666    #[serde(default = "default_tab_inactive_bg")]
667    pub tab_inactive_bg: ColorDef,
668    /// Tab bar separator color
669    #[serde(default = "default_tab_separator_bg")]
670    pub tab_separator_bg: ColorDef,
671    /// Tab close button hover color
672    #[serde(default = "default_tab_close_hover_fg")]
673    pub tab_close_hover_fg: ColorDef,
674    /// Tab hover background color
675    #[serde(default = "default_tab_hover_bg")]
676    pub tab_hover_bg: ColorDef,
677    /// Menu bar background
678    #[serde(default = "default_menu_bg")]
679    pub menu_bg: ColorDef,
680    /// Menu bar text color
681    #[serde(default = "default_menu_fg")]
682    pub menu_fg: ColorDef,
683    /// Active menu item background
684    #[serde(default = "default_menu_active_bg")]
685    pub menu_active_bg: ColorDef,
686    /// Active menu item text color
687    #[serde(default = "default_menu_active_fg")]
688    pub menu_active_fg: ColorDef,
689    /// Dropdown menu background
690    #[serde(default = "default_menu_dropdown_bg")]
691    pub menu_dropdown_bg: ColorDef,
692    /// Dropdown menu text color
693    #[serde(default = "default_menu_dropdown_fg")]
694    pub menu_dropdown_fg: ColorDef,
695    /// Highlighted menu item background
696    #[serde(default = "default_menu_highlight_bg")]
697    pub menu_highlight_bg: ColorDef,
698    /// Highlighted menu item text color
699    #[serde(default = "default_menu_highlight_fg")]
700    pub menu_highlight_fg: ColorDef,
701    /// Menu border color
702    #[serde(default = "default_menu_border_fg")]
703    pub menu_border_fg: ColorDef,
704    /// Menu separator line color
705    #[serde(default = "default_menu_separator_fg")]
706    pub menu_separator_fg: ColorDef,
707    /// Menu item hover background
708    #[serde(default = "default_menu_hover_bg")]
709    pub menu_hover_bg: ColorDef,
710    /// Menu item hover text color
711    #[serde(default = "default_menu_hover_fg")]
712    pub menu_hover_fg: ColorDef,
713    /// Disabled menu item text color
714    #[serde(default = "default_menu_disabled_fg")]
715    pub menu_disabled_fg: ColorDef,
716    /// Disabled menu item background
717    #[serde(default = "default_menu_disabled_bg")]
718    pub menu_disabled_bg: ColorDef,
719    /// Status bar text color
720    #[serde(default = "default_status_bar_fg")]
721    pub status_bar_fg: ColorDef,
722    /// Status bar background color
723    #[serde(default = "default_status_bar_bg")]
724    pub status_bar_bg: ColorDef,
725    /// Command palette shortcut hint text color in status bar (falls back to status_bar_fg)
726    #[serde(default)]
727    pub status_palette_fg: Option<ColorDef>,
728    /// Command palette shortcut hint background in status bar (falls back to status_bar_bg)
729    #[serde(default)]
730    pub status_palette_bg: Option<ColorDef>,
731    /// Status bar separator glyph text color (falls back to status_bar_fg)
732    #[serde(default)]
733    pub status_separator_fg: Option<ColorDef>,
734    /// Status bar separator glyph background (falls back to status_bar_bg)
735    #[serde(default)]
736    pub status_separator_bg: Option<ColorDef>,
737    /// Status bar LSP indicator text color when LSP is running (falls back to status_bar_fg)
738    #[serde(default)]
739    pub status_lsp_on_fg: Option<ColorDef>,
740    /// Status bar LSP indicator background when LSP is running (falls back to status_bar_bg)
741    #[serde(default)]
742    pub status_lsp_on_bg: Option<ColorDef>,
743    /// Status bar LSP indicator text color when LSP options are available
744    /// to act on (configured-but-not-running). Drawn prominently to signal
745    /// "click here to enable". Falls back to `status_warning_indicator_fg`.
746    #[serde(default)]
747    pub status_lsp_actionable_fg: Option<ColorDef>,
748    /// Status bar LSP indicator background when LSP options are available
749    /// to act on. Falls back to `status_warning_indicator_bg`.
750    #[serde(default)]
751    pub status_lsp_actionable_bg: Option<ColorDef>,
752    /// Command prompt text color
753    #[serde(default = "default_prompt_fg")]
754    pub prompt_fg: ColorDef,
755    /// Command prompt background
756    #[serde(default = "default_prompt_bg")]
757    pub prompt_bg: ColorDef,
758    /// Prompt selected text color
759    #[serde(default = "default_prompt_selection_fg")]
760    pub prompt_selection_fg: ColorDef,
761    /// Prompt selection background
762    #[serde(default = "default_prompt_selection_bg")]
763    pub prompt_selection_bg: ColorDef,
764    /// Popup window border color
765    #[serde(default = "default_popup_border_fg")]
766    pub popup_border_fg: ColorDef,
767    /// Popup window background
768    #[serde(default = "default_popup_bg")]
769    pub popup_bg: ColorDef,
770    /// Popup selected item background
771    #[serde(default = "default_popup_selection_bg")]
772    pub popup_selection_bg: ColorDef,
773    /// Selection background inside a widget Text input. Reads
774    /// against `prompt_bg`, so it needs higher contrast against
775    /// that tint than `editor.selection_bg` (which targets the
776    /// editor surface). Defaults to the same `popup_selection_bg`
777    /// blue used everywhere "selected item inside a chrome
778    /// surface" is shown — same key the prompt selection uses, so
779    /// the cue reads consistently across selection UIs.
780    #[serde(default = "default_text_input_selection_bg")]
781    pub text_input_selection_bg: ColorDef,
782    /// Popup selected item text color
783    #[serde(default = "default_popup_selection_fg")]
784    pub popup_selection_fg: ColorDef,
785    /// Popup window text color. Per the `*_bg`/`*_fg` convention this
786    /// is the foreground for `popup_bg`; `popup_fg` is accepted as an
787    /// alias so theme JSON can use the convention-consistent name.
788    #[serde(default = "default_popup_text_fg", alias = "popup_fg")]
789    pub popup_text_fg: ColorDef,
790    /// Autocomplete suggestion background
791    #[serde(default = "default_suggestion_bg")]
792    pub suggestion_bg: ColorDef,
793    /// Text color for content drawn on `suggestion_bg` (autocomplete
794    /// items, the overlay-prompt title/input field). Falls back to
795    /// `popup_text_fg` so existing themes need no change.
796    #[serde(default)]
797    pub suggestion_fg: Option<ColorDef>,
798    /// Selected suggestion background
799    #[serde(default = "default_suggestion_selected_bg")]
800    pub suggestion_selected_bg: ColorDef,
801    /// Help panel background
802    #[serde(default = "default_help_bg")]
803    pub help_bg: ColorDef,
804    /// Help panel text color
805    #[serde(default = "default_help_fg")]
806    pub help_fg: ColorDef,
807    /// Help keybinding text color
808    #[serde(default = "default_help_key_fg")]
809    pub help_key_fg: ColorDef,
810    /// Help panel separator color
811    #[serde(default = "default_help_separator_fg")]
812    pub help_separator_fg: ColorDef,
813    /// Help indicator text color
814    #[serde(default = "default_help_indicator_fg")]
815    pub help_indicator_fg: ColorDef,
816    /// Help indicator background
817    #[serde(default = "default_help_indicator_bg")]
818    pub help_indicator_bg: ColorDef,
819    /// Inline code block background
820    #[serde(default = "default_inline_code_bg")]
821    pub inline_code_bg: ColorDef,
822    /// Split pane separator color
823    #[serde(default = "default_split_separator_fg")]
824    pub split_separator_fg: ColorDef,
825    /// Split separator hover color
826    #[serde(default = "default_split_separator_hover_fg")]
827    pub split_separator_hover_fg: ColorDef,
828    /// Scrollbar track color
829    #[serde(default = "default_scrollbar_track_fg")]
830    pub scrollbar_track_fg: ColorDef,
831    /// Scrollbar thumb color
832    #[serde(default = "default_scrollbar_thumb_fg")]
833    pub scrollbar_thumb_fg: ColorDef,
834    /// Scrollbar track hover color
835    #[serde(default = "default_scrollbar_track_hover_fg")]
836    pub scrollbar_track_hover_fg: ColorDef,
837    /// Scrollbar thumb hover color
838    #[serde(default = "default_scrollbar_thumb_hover_fg")]
839    pub scrollbar_thumb_hover_fg: ColorDef,
840    /// Compose mode margin background
841    #[serde(default = "default_compose_margin_bg")]
842    pub compose_margin_bg: ColorDef,
843    /// Word under cursor highlight
844    #[serde(default = "default_semantic_highlight_bg")]
845    pub semantic_highlight_bg: ColorDef,
846    /// Optional text-attribute modifiers (e.g. `["bold"]` or
847    /// `["reversed"]`) layered on top of `semantic_highlight_bg`.
848    /// Per the canonical native-palette pattern, current-word
849    /// highlights are often shown via `Bold` (so the word stands
850    /// out against other variables without altering its color slot)
851    /// or `Reversed`. See `EditorColors::selection_modifier`.
852    #[serde(default)]
853    pub semantic_highlight_modifier: Option<ModifierDef>,
854    /// Embedded terminal background (use Default for transparency)
855    #[serde(default = "default_terminal_bg")]
856    pub terminal_bg: ColorDef,
857    /// Embedded terminal default text color
858    #[serde(default = "default_terminal_fg")]
859    pub terminal_fg: ColorDef,
860    /// Warning indicator background in status bar
861    #[serde(default = "default_status_warning_indicator_bg")]
862    pub status_warning_indicator_bg: ColorDef,
863    /// Warning indicator text color in status bar
864    #[serde(default = "default_status_warning_indicator_fg")]
865    pub status_warning_indicator_fg: ColorDef,
866    /// Error indicator background in status bar
867    #[serde(default = "default_status_error_indicator_bg")]
868    pub status_error_indicator_bg: ColorDef,
869    /// Error indicator text color in status bar
870    #[serde(default = "default_status_error_indicator_fg")]
871    pub status_error_indicator_fg: ColorDef,
872    /// Warning indicator hover background
873    #[serde(default = "default_status_warning_indicator_hover_bg")]
874    pub status_warning_indicator_hover_bg: ColorDef,
875    /// Warning indicator hover text color
876    #[serde(default = "default_status_warning_indicator_hover_fg")]
877    pub status_warning_indicator_hover_fg: ColorDef,
878    /// Error indicator hover background
879    #[serde(default = "default_status_error_indicator_hover_bg")]
880    pub status_error_indicator_hover_bg: ColorDef,
881    /// Error indicator hover text color
882    #[serde(default = "default_status_error_indicator_hover_fg")]
883    pub status_error_indicator_hover_fg: ColorDef,
884    /// Tab drop zone background during drag
885    #[serde(default = "default_tab_drop_zone_bg")]
886    pub tab_drop_zone_bg: ColorDef,
887    /// Tab drop zone border during drag
888    #[serde(default = "default_tab_drop_zone_border")]
889    pub tab_drop_zone_border: ColorDef,
890    /// Settings UI selected item background
891    #[serde(default = "default_settings_selected_bg")]
892    pub settings_selected_bg: ColorDef,
893    /// Settings UI selected item foreground (text on selected background)
894    #[serde(default = "default_settings_selected_fg")]
895    pub settings_selected_fg: ColorDef,
896    /// File status: added file color in file explorer (falls back to diagnostic.info_fg)
897    #[serde(default)]
898    pub file_status_added_fg: Option<ColorDef>,
899    /// File status: modified file color in file explorer (falls back to diagnostic.warning_fg)
900    #[serde(default)]
901    pub file_status_modified_fg: Option<ColorDef>,
902    /// File status: deleted file color in file explorer (falls back to diagnostic.error_fg)
903    #[serde(default)]
904    pub file_status_deleted_fg: Option<ColorDef>,
905    /// File status: renamed file color in file explorer (falls back to diagnostic.info_fg)
906    #[serde(default)]
907    pub file_status_renamed_fg: Option<ColorDef>,
908    /// File status: untracked file color in file explorer (falls back to diagnostic.hint_fg)
909    #[serde(default)]
910    pub file_status_untracked_fg: Option<ColorDef>,
911    /// File status: conflicted file color in file explorer (falls back to diagnostic.error_fg)
912    #[serde(default)]
913    pub file_status_conflicted_fg: Option<ColorDef>,
914}
915
916// Default tab close hover color (for backward compatibility with existing themes)
917// Default tab colors (for minimal themes)
918fn default_tab_active_fg() -> ColorDef {
919    ColorDef::Named("Yellow".to_string())
920}
921fn default_tab_active_bg() -> ColorDef {
922    ColorDef::Named("Blue".to_string())
923}
924fn default_tab_inactive_fg() -> ColorDef {
925    ColorDef::Named("White".to_string())
926}
927fn default_tab_inactive_bg() -> ColorDef {
928    ColorDef::Named("DarkGray".to_string())
929}
930fn default_tab_separator_bg() -> ColorDef {
931    ColorDef::Named("Black".to_string())
932}
933fn default_tab_close_hover_fg() -> ColorDef {
934    ColorDef::Rgb(255, 100, 100) // Red-ish color for close button hover
935}
936fn default_tab_hover_bg() -> ColorDef {
937    ColorDef::Rgb(70, 70, 75) // Slightly lighter than inactive tab bg for hover
938}
939
940// Default menu colors (for backward compatibility with existing themes)
941fn default_menu_bg() -> ColorDef {
942    ColorDef::Rgb(60, 60, 65)
943}
944fn default_menu_fg() -> ColorDef {
945    ColorDef::Rgb(220, 220, 220)
946}
947fn default_menu_active_bg() -> ColorDef {
948    ColorDef::Rgb(60, 60, 60)
949}
950fn default_menu_active_fg() -> ColorDef {
951    ColorDef::Rgb(255, 255, 255)
952}
953fn default_menu_dropdown_bg() -> ColorDef {
954    ColorDef::Rgb(50, 50, 50)
955}
956fn default_menu_dropdown_fg() -> ColorDef {
957    ColorDef::Rgb(220, 220, 220)
958}
959fn default_menu_highlight_bg() -> ColorDef {
960    ColorDef::Rgb(70, 130, 180)
961}
962fn default_menu_highlight_fg() -> ColorDef {
963    ColorDef::Rgb(255, 255, 255)
964}
965fn default_menu_border_fg() -> ColorDef {
966    ColorDef::Rgb(100, 100, 100)
967}
968fn default_menu_separator_fg() -> ColorDef {
969    ColorDef::Rgb(80, 80, 80)
970}
971fn default_menu_hover_bg() -> ColorDef {
972    ColorDef::Rgb(55, 55, 55)
973}
974fn default_menu_hover_fg() -> ColorDef {
975    ColorDef::Rgb(255, 255, 255)
976}
977fn default_menu_disabled_fg() -> ColorDef {
978    ColorDef::Rgb(100, 100, 100) // Gray for disabled items
979}
980fn default_menu_disabled_bg() -> ColorDef {
981    ColorDef::Rgb(50, 50, 50) // Same as dropdown bg
982}
983// Default status bar colors
984fn default_status_bar_fg() -> ColorDef {
985    ColorDef::Named("White".to_string())
986}
987fn default_status_bar_bg() -> ColorDef {
988    ColorDef::Named("DarkGray".to_string())
989}
990
991// Default prompt colors
992fn default_prompt_fg() -> ColorDef {
993    ColorDef::Named("White".to_string())
994}
995fn default_prompt_bg() -> ColorDef {
996    ColorDef::Named("Black".to_string())
997}
998fn default_prompt_selection_fg() -> ColorDef {
999    ColorDef::Named("White".to_string())
1000}
1001fn default_prompt_selection_bg() -> ColorDef {
1002    ColorDef::Rgb(58, 79, 120)
1003}
1004
1005// Default popup colors
1006pub(crate) fn default_popup_border_fg() -> ColorDef {
1007    ColorDef::Named("Gray".to_string())
1008}
1009pub(crate) fn default_popup_bg() -> ColorDef {
1010    ColorDef::Rgb(30, 30, 30)
1011}
1012fn default_popup_selection_bg() -> ColorDef {
1013    ColorDef::Rgb(58, 79, 120)
1014}
1015fn default_text_input_selection_bg() -> ColorDef {
1016    // Match the popup-selection blue. Widget Text inputs sit on a
1017    // `prompt_bg` field tint, so the selection needs the same
1018    // "selected on chrome" contrast that popups + prompts use.
1019    ColorDef::Rgb(58, 79, 120)
1020}
1021fn default_popup_selection_fg() -> ColorDef {
1022    ColorDef::Rgb(255, 255, 255) // White text on selected popup item
1023}
1024fn default_popup_text_fg() -> ColorDef {
1025    ColorDef::Named("White".to_string())
1026}
1027
1028// Default suggestion colors
1029fn default_suggestion_bg() -> ColorDef {
1030    ColorDef::Rgb(30, 30, 30)
1031}
1032fn default_suggestion_selected_bg() -> ColorDef {
1033    ColorDef::Rgb(58, 79, 120)
1034}
1035
1036// Default help colors
1037fn default_help_bg() -> ColorDef {
1038    ColorDef::Named("Black".to_string())
1039}
1040fn default_help_fg() -> ColorDef {
1041    ColorDef::Named("White".to_string())
1042}
1043fn default_help_key_fg() -> ColorDef {
1044    ColorDef::Named("Cyan".to_string())
1045}
1046fn default_help_separator_fg() -> ColorDef {
1047    ColorDef::Named("DarkGray".to_string())
1048}
1049fn default_help_indicator_fg() -> ColorDef {
1050    ColorDef::Named("Red".to_string())
1051}
1052fn default_help_indicator_bg() -> ColorDef {
1053    ColorDef::Named("Black".to_string())
1054}
1055
1056fn default_inline_code_bg() -> ColorDef {
1057    ColorDef::Named("DarkGray".to_string())
1058}
1059
1060// Default split separator colors
1061fn default_split_separator_fg() -> ColorDef {
1062    ColorDef::Rgb(100, 100, 100)
1063}
1064fn default_split_separator_hover_fg() -> ColorDef {
1065    ColorDef::Rgb(100, 149, 237) // Cornflower blue for visibility
1066}
1067fn default_scrollbar_track_fg() -> ColorDef {
1068    ColorDef::Named("DarkGray".to_string())
1069}
1070fn default_scrollbar_thumb_fg() -> ColorDef {
1071    ColorDef::Named("Gray".to_string())
1072}
1073fn default_scrollbar_track_hover_fg() -> ColorDef {
1074    ColorDef::Named("Gray".to_string())
1075}
1076fn default_scrollbar_thumb_hover_fg() -> ColorDef {
1077    ColorDef::Named("White".to_string())
1078}
1079fn default_compose_margin_bg() -> ColorDef {
1080    ColorDef::Rgb(18, 18, 18) // Darker than editor_bg for "desk" effect
1081}
1082fn default_semantic_highlight_bg() -> ColorDef {
1083    ColorDef::Rgb(60, 60, 80) // Subtle dark highlight for word occurrences
1084}
1085fn default_terminal_bg() -> ColorDef {
1086    ColorDef::Named("Default".to_string()) // Use terminal's default background (preserves transparency)
1087}
1088fn default_terminal_fg() -> ColorDef {
1089    ColorDef::Named("Default".to_string()) // Use terminal's default foreground
1090}
1091fn default_status_warning_indicator_bg() -> ColorDef {
1092    ColorDef::Rgb(181, 137, 0) // Solarized yellow/amber - noticeable but not harsh
1093}
1094fn default_status_warning_indicator_fg() -> ColorDef {
1095    ColorDef::Rgb(0, 0, 0) // Black text on amber background
1096}
1097fn default_status_error_indicator_bg() -> ColorDef {
1098    ColorDef::Rgb(220, 50, 47) // Solarized red - clearly an error
1099}
1100fn default_status_error_indicator_fg() -> ColorDef {
1101    ColorDef::Rgb(255, 255, 255) // White text on red background
1102}
1103fn default_status_warning_indicator_hover_bg() -> ColorDef {
1104    ColorDef::Rgb(211, 167, 30) // Lighter amber for hover
1105}
1106fn default_status_warning_indicator_hover_fg() -> ColorDef {
1107    ColorDef::Rgb(0, 0, 0) // Black text on hover
1108}
1109fn default_status_error_indicator_hover_bg() -> ColorDef {
1110    ColorDef::Rgb(250, 80, 77) // Lighter red for hover
1111}
1112fn default_status_error_indicator_hover_fg() -> ColorDef {
1113    ColorDef::Rgb(255, 255, 255) // White text on hover
1114}
1115fn default_tab_drop_zone_bg() -> ColorDef {
1116    ColorDef::Rgb(70, 130, 180) // Steel blue with transparency effect
1117}
1118fn default_tab_drop_zone_border() -> ColorDef {
1119    ColorDef::Rgb(100, 149, 237) // Cornflower blue for border
1120}
1121fn default_settings_selected_bg() -> ColorDef {
1122    ColorDef::Rgb(60, 60, 70) // Subtle highlight for selected settings item
1123}
1124fn default_settings_selected_fg() -> ColorDef {
1125    ColorDef::Rgb(255, 255, 255) // White text on selected background
1126}
1127/// Search result highlighting colors
1128#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1129pub struct SearchColors {
1130    /// Search match background color
1131    #[serde(default = "default_search_match_bg")]
1132    pub match_bg: ColorDef,
1133    /// Search match text color
1134    #[serde(default = "default_search_match_fg")]
1135    pub match_fg: ColorDef,
1136    /// Background color for jump labels (e.g. flash plugin labels).
1137    /// Should be visually distinct from `match_bg` so labels stand
1138    /// out against highlighted matches.  Default: bright magenta.
1139    #[serde(default = "default_search_label_bg")]
1140    pub label_bg: ColorDef,
1141    /// Foreground color for jump labels.  Should be high contrast
1142    /// against `label_bg` so the single label letter is unambiguous
1143    /// even on small terminal cells.  Default: white.
1144    #[serde(default = "default_search_label_fg")]
1145    pub label_fg: ColorDef,
1146}
1147
1148// Default search colors
1149fn default_search_match_bg() -> ColorDef {
1150    ColorDef::Rgb(100, 100, 20)
1151}
1152fn default_search_match_fg() -> ColorDef {
1153    ColorDef::Rgb(255, 255, 255)
1154}
1155// Mirrors flash.nvim's default FlashLabel (links to Substitute, which
1156// is a magenta-family colour in most colorschemes).  The pairing is
1157// chosen so labels pop visually distinct from `search.match_bg`
1158// (typically yellow / orange).
1159fn default_search_label_bg() -> ColorDef {
1160    ColorDef::Rgb(199, 78, 189)
1161}
1162fn default_search_label_fg() -> ColorDef {
1163    ColorDef::Rgb(255, 255, 255)
1164}
1165
1166/// LSP diagnostic colors (errors, warnings, etc.)
1167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1168pub struct DiagnosticColors {
1169    /// Error message text color
1170    #[serde(default = "default_diagnostic_error_fg")]
1171    pub error_fg: ColorDef,
1172    /// Error highlight background
1173    #[serde(default = "default_diagnostic_error_bg")]
1174    pub error_bg: ColorDef,
1175    /// Warning message text color
1176    #[serde(default = "default_diagnostic_warning_fg")]
1177    pub warning_fg: ColorDef,
1178    /// Warning highlight background
1179    #[serde(default = "default_diagnostic_warning_bg")]
1180    pub warning_bg: ColorDef,
1181    /// Info message text color
1182    #[serde(default = "default_diagnostic_info_fg")]
1183    pub info_fg: ColorDef,
1184    /// Info highlight background
1185    #[serde(default = "default_diagnostic_info_bg")]
1186    pub info_bg: ColorDef,
1187    /// Hint message text color
1188    #[serde(default = "default_diagnostic_hint_fg")]
1189    pub hint_fg: ColorDef,
1190    /// Hint highlight background
1191    #[serde(default = "default_diagnostic_hint_bg")]
1192    pub hint_bg: ColorDef,
1193}
1194
1195// Default diagnostic colors
1196fn default_diagnostic_error_fg() -> ColorDef {
1197    ColorDef::Named("Red".to_string())
1198}
1199fn default_diagnostic_error_bg() -> ColorDef {
1200    ColorDef::Rgb(60, 20, 20)
1201}
1202fn default_diagnostic_warning_fg() -> ColorDef {
1203    ColorDef::Named("Yellow".to_string())
1204}
1205fn default_diagnostic_warning_bg() -> ColorDef {
1206    ColorDef::Rgb(60, 50, 0)
1207}
1208fn default_diagnostic_info_fg() -> ColorDef {
1209    ColorDef::Named("Blue".to_string())
1210}
1211fn default_diagnostic_info_bg() -> ColorDef {
1212    ColorDef::Rgb(0, 30, 60)
1213}
1214fn default_diagnostic_hint_fg() -> ColorDef {
1215    ColorDef::Named("Gray".to_string())
1216}
1217fn default_diagnostic_hint_bg() -> ColorDef {
1218    ColorDef::Rgb(30, 30, 30)
1219}
1220
1221/// Syntax highlighting colors
1222#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1223pub struct SyntaxColors {
1224    /// Language keywords (if, for, fn, etc.)
1225    #[serde(default = "default_syntax_keyword")]
1226    pub keyword: ColorDef,
1227    /// String literals
1228    #[serde(default = "default_syntax_string")]
1229    pub string: ColorDef,
1230    /// Code comments
1231    #[serde(default = "default_syntax_comment")]
1232    pub comment: ColorDef,
1233    /// Function names
1234    #[serde(default = "default_syntax_function")]
1235    pub function: ColorDef,
1236    /// Type names
1237    #[serde(rename = "type", default = "default_syntax_type")]
1238    pub type_: ColorDef,
1239    /// Variable names
1240    #[serde(default = "default_syntax_variable")]
1241    pub variable: ColorDef,
1242    /// Built-in language variables (self, this, super, etc.)
1243    #[serde(default = "default_syntax_variable_builtin")]
1244    pub variable_builtin: ColorDef,
1245    /// Constants and literals
1246    #[serde(default = "default_syntax_constant")]
1247    pub constant: ColorDef,
1248    /// Operators (+, -, =, etc.)
1249    #[serde(default = "default_syntax_operator")]
1250    pub operator: ColorDef,
1251    /// Punctuation brackets ({, }, (, ), [, ])
1252    #[serde(default = "default_syntax_punctuation_bracket")]
1253    pub punctuation_bracket: ColorDef,
1254    /// Punctuation delimiters (;, ,, .)
1255    #[serde(default = "default_syntax_punctuation_delimiter")]
1256    pub punctuation_delimiter: ColorDef,
1257}
1258
1259// Default syntax colors (VSCode Dark+ inspired)
1260fn default_syntax_keyword() -> ColorDef {
1261    ColorDef::Rgb(86, 156, 214)
1262}
1263fn default_syntax_string() -> ColorDef {
1264    ColorDef::Rgb(206, 145, 120)
1265}
1266fn default_syntax_comment() -> ColorDef {
1267    ColorDef::Rgb(106, 153, 85)
1268}
1269fn default_syntax_function() -> ColorDef {
1270    ColorDef::Rgb(220, 220, 170)
1271}
1272fn default_syntax_type() -> ColorDef {
1273    ColorDef::Rgb(78, 201, 176)
1274}
1275fn default_syntax_variable() -> ColorDef {
1276    ColorDef::Rgb(156, 220, 254)
1277}
1278fn default_syntax_variable_builtin() -> ColorDef {
1279    ColorDef::Rgb(86, 156, 214) // same as keyword — self/this/super are language-defined
1280}
1281fn default_syntax_constant() -> ColorDef {
1282    ColorDef::Rgb(79, 193, 255)
1283}
1284fn default_syntax_operator() -> ColorDef {
1285    ColorDef::Rgb(212, 212, 212)
1286}
1287fn default_syntax_punctuation_bracket() -> ColorDef {
1288    ColorDef::Rgb(212, 212, 212) // default foreground — brackets blend with text
1289}
1290fn default_syntax_punctuation_delimiter() -> ColorDef {
1291    ColorDef::Rgb(212, 212, 212) // default foreground — delimiters blend with text
1292}
1293
1294/// Comprehensive theme structure with all UI colors
1295#[derive(Debug, Clone)]
1296pub struct Theme {
1297    /// Theme name (e.g., "dark", "light", "high-contrast")
1298    pub name: String,
1299
1300    // Editor colors
1301    pub editor_bg: Color,
1302    pub editor_fg: Color,
1303    pub cursor: Color,
1304    pub inactive_cursor: Color,
1305    pub selection_bg: Color,
1306    /// SGR text attributes layered onto selected cells. Empty for
1307    /// traditional themes; native-palette themes set
1308    /// `Modifier::REVERSED` so the selection inverts the terminal's
1309    /// current fg/bg (vim/neovim Visual, helix term16, htop, less).
1310    pub selection_modifier: Modifier,
1311    pub current_line_bg: Color,
1312    pub line_number_fg: Color,
1313    pub line_number_bg: Color,
1314
1315    /// Background color for rows past end-of-file
1316    pub after_eof_bg: Color,
1317
1318    // Vertical ruler color
1319    pub ruler_bg: Color,
1320
1321    // Indentation guide color
1322    pub indentation_guide_fg: Color,
1323
1324    // Whitespace indicator color (tab arrows, space dots)
1325    pub whitespace_indicator_fg: Color,
1326
1327    // Bracket matching colors
1328    pub bracket_match_fg: Color,
1329    pub bracket_rainbow_1: Color,
1330    pub bracket_rainbow_2: Color,
1331    pub bracket_rainbow_3: Color,
1332    pub bracket_rainbow_4: Color,
1333    pub bracket_rainbow_5: Color,
1334    pub bracket_rainbow_6: Color,
1335
1336    // Diff highlighting colors
1337    pub diff_add_bg: Color,
1338    pub diff_remove_bg: Color,
1339    pub diff_modify_bg: Color,
1340    /// Brighter background for inline diff highlighting on added content
1341    pub diff_add_highlight_bg: Color,
1342    /// Brighter background for inline diff highlighting on removed content
1343    pub diff_remove_highlight_bg: Color,
1344    /// Collision-only fg fallback for cells whose existing fg matches
1345    /// `diff_*_bg`. `None` keeps the cell's original fg; overlays opt
1346    /// into the override via `fg_on_collision_only`.
1347    pub diff_add_collision_fg: Option<Color>,
1348    pub diff_remove_collision_fg: Option<Color>,
1349    pub diff_modify_collision_fg: Option<Color>,
1350
1351    // UI element colors
1352    pub tab_active_fg: Color,
1353    pub tab_active_bg: Color,
1354    pub tab_inactive_fg: Color,
1355    pub tab_inactive_bg: Color,
1356    pub tab_separator_bg: Color,
1357    pub tab_close_hover_fg: Color,
1358    pub tab_hover_bg: Color,
1359
1360    // Menu bar colors
1361    pub menu_bg: Color,
1362    pub menu_fg: Color,
1363    pub menu_active_bg: Color,
1364    pub menu_active_fg: Color,
1365    pub menu_dropdown_bg: Color,
1366    pub menu_dropdown_fg: Color,
1367    pub menu_highlight_bg: Color,
1368    pub menu_highlight_fg: Color,
1369    pub menu_border_fg: Color,
1370    pub menu_separator_fg: Color,
1371    pub menu_hover_bg: Color,
1372    pub menu_hover_fg: Color,
1373    pub menu_disabled_fg: Color,
1374    pub menu_disabled_bg: Color,
1375
1376    pub status_bar_fg: Color,
1377    pub status_bar_bg: Color,
1378    /// Status bar palette shortcut hint colors (default: same as status bar)
1379    pub status_palette_fg: Color,
1380    pub status_palette_bg: Color,
1381    /// Status bar separator glyph colors (default: same as status bar)
1382    pub status_separator_fg: Color,
1383    pub status_separator_bg: Color,
1384    /// Status bar LSP indicator colors when running (default: same as status bar)
1385    pub status_lsp_on_fg: Color,
1386    pub status_lsp_on_bg: Color,
1387    /// Status bar LSP indicator colors when actionable options are available
1388    /// (configured-but-not-running). Default: same as status warning indicator.
1389    pub status_lsp_actionable_fg: Color,
1390    pub status_lsp_actionable_bg: Color,
1391    pub prompt_fg: Color,
1392    pub prompt_bg: Color,
1393    pub prompt_selection_fg: Color,
1394    pub prompt_selection_bg: Color,
1395
1396    pub popup_border_fg: Color,
1397    pub popup_bg: Color,
1398    pub popup_selection_bg: Color,
1399    pub popup_selection_fg: Color,
1400    pub popup_text_fg: Color,
1401    /// Background for the selection span inside a widget Text
1402    /// input. See the file-format field doc for why this isn't
1403    /// just `editor.selection_bg`.
1404    pub text_input_selection_bg: Color,
1405
1406    pub suggestion_bg: Color,
1407    pub suggestion_fg: Color,
1408    pub suggestion_selected_bg: Color,
1409
1410    pub help_bg: Color,
1411    pub help_fg: Color,
1412    pub help_key_fg: Color,
1413    pub help_separator_fg: Color,
1414
1415    pub help_indicator_fg: Color,
1416    pub help_indicator_bg: Color,
1417
1418    /// Background color for inline code in help popups
1419    pub inline_code_bg: Color,
1420
1421    pub split_separator_fg: Color,
1422    pub split_separator_hover_fg: Color,
1423
1424    // Scrollbar colors
1425    pub scrollbar_track_fg: Color,
1426    pub scrollbar_thumb_fg: Color,
1427    pub scrollbar_track_hover_fg: Color,
1428    pub scrollbar_thumb_hover_fg: Color,
1429
1430    // Compose mode colors
1431    pub compose_margin_bg: Color,
1432
1433    // Semantic highlighting (word under cursor)
1434    pub semantic_highlight_bg: Color,
1435    /// SGR text attributes layered onto current-word-highlight cells.
1436    /// Native-palette themes typically set `Modifier::BOLD` (so the
1437    /// word stands out without altering its color slot) or
1438    /// `Modifier::REVERSED`.
1439    pub semantic_highlight_modifier: Modifier,
1440
1441    // Terminal colors (for embedded terminal buffers)
1442    pub terminal_bg: Color,
1443    pub terminal_fg: Color,
1444
1445    // Status bar warning/error indicator colors
1446    pub status_warning_indicator_bg: Color,
1447    pub status_warning_indicator_fg: Color,
1448    pub status_error_indicator_bg: Color,
1449    pub status_error_indicator_fg: Color,
1450    pub status_warning_indicator_hover_bg: Color,
1451    pub status_warning_indicator_hover_fg: Color,
1452    pub status_error_indicator_hover_bg: Color,
1453    pub status_error_indicator_hover_fg: Color,
1454
1455    // Tab drag-and-drop colors
1456    pub tab_drop_zone_bg: Color,
1457    pub tab_drop_zone_border: Color,
1458
1459    // Settings UI colors
1460    pub settings_selected_bg: Color,
1461    pub settings_selected_fg: Color,
1462
1463    // File status colors (git status indicators in file explorer)
1464    pub file_status_added_fg: Color,
1465    pub file_status_modified_fg: Color,
1466    pub file_status_deleted_fg: Color,
1467    pub file_status_renamed_fg: Color,
1468    pub file_status_untracked_fg: Color,
1469    pub file_status_conflicted_fg: Color,
1470
1471    // Search colors
1472    pub search_match_bg: Color,
1473    pub search_match_fg: Color,
1474    pub search_label_bg: Color,
1475    pub search_label_fg: Color,
1476
1477    // Diagnostic colors
1478    pub diagnostic_error_fg: Color,
1479    pub diagnostic_error_bg: Color,
1480    pub diagnostic_warning_fg: Color,
1481    pub diagnostic_warning_bg: Color,
1482    pub diagnostic_info_fg: Color,
1483    pub diagnostic_info_bg: Color,
1484    pub diagnostic_hint_fg: Color,
1485    pub diagnostic_hint_bg: Color,
1486
1487    // Syntax highlighting colors
1488    pub syntax_keyword: Color,
1489    pub syntax_string: Color,
1490    pub syntax_comment: Color,
1491    pub syntax_function: Color,
1492    pub syntax_type: Color,
1493    pub syntax_variable: Color,
1494    pub syntax_variable_builtin: Color,
1495    pub syntax_constant: Color,
1496    pub syntax_operator: Color,
1497    pub syntax_punctuation_bracket: Color,
1498    pub syntax_punctuation_delimiter: Color,
1499}
1500
1501impl From<ThemeFile> for Theme {
1502    fn from(file: ThemeFile) -> Self {
1503        Self {
1504            name: file.name,
1505            editor_bg: file.editor.bg.clone().into(),
1506            editor_fg: file.editor.fg.into(),
1507            cursor: file.editor.cursor.into(),
1508            inactive_cursor: file.editor.inactive_cursor.into(),
1509            selection_bg: file.editor.selection_bg.into(),
1510            selection_modifier: file
1511                .editor
1512                .selection_modifier
1513                .as_ref()
1514                .map(Modifier::from)
1515                .unwrap_or(Modifier::empty()),
1516            current_line_bg: file.editor.current_line_bg.into(),
1517            line_number_fg: file.editor.line_number_fg.into(),
1518            line_number_bg: file.editor.line_number_bg.into(),
1519            // Use explicit override if provided, otherwise derive a subtle
1520            // contrasting shade from the editor background.
1521            after_eof_bg: file
1522                .editor
1523                .after_eof_bg
1524                .clone()
1525                .map(|c| c.into())
1526                .unwrap_or_else(|| shade_toward_contrast(file.editor.bg.clone().into(), 10)),
1527            ruler_bg: file.editor.ruler_bg.into(),
1528            indentation_guide_fg: file
1529                .editor
1530                .indentation_guide_fg
1531                .clone()
1532                .unwrap_or_else(|| file.editor.whitespace_indicator_fg.clone())
1533                .into(),
1534            whitespace_indicator_fg: file.editor.whitespace_indicator_fg.into(),
1535            bracket_match_fg: file.editor.bracket_match_fg.into(),
1536            bracket_rainbow_1: file.editor.bracket_rainbow_1.into(),
1537            bracket_rainbow_2: file.editor.bracket_rainbow_2.into(),
1538            bracket_rainbow_3: file.editor.bracket_rainbow_3.into(),
1539            bracket_rainbow_4: file.editor.bracket_rainbow_4.into(),
1540            bracket_rainbow_5: file.editor.bracket_rainbow_5.into(),
1541            bracket_rainbow_6: file.editor.bracket_rainbow_6.into(),
1542            diff_add_bg: file.editor.diff_add_bg.clone().into(),
1543            diff_remove_bg: file.editor.diff_remove_bg.clone().into(),
1544            diff_modify_bg: file.editor.diff_modify_bg.into(),
1545            // Use explicit override if provided, otherwise brighten from base
1546            diff_add_highlight_bg: file
1547                .editor
1548                .diff_add_highlight_bg
1549                .map(|c| c.into())
1550                .unwrap_or_else(|| brighten_color(file.editor.diff_add_bg.into(), 40)),
1551            diff_remove_highlight_bg: file
1552                .editor
1553                .diff_remove_highlight_bg
1554                .map(|c| c.into())
1555                .unwrap_or_else(|| brighten_color(file.editor.diff_remove_bg.into(), 40)),
1556            diff_add_collision_fg: file.editor.diff_add_collision_fg.clone().map(|c| c.into()),
1557            diff_remove_collision_fg: file
1558                .editor
1559                .diff_remove_collision_fg
1560                .clone()
1561                .map(|c| c.into()),
1562            diff_modify_collision_fg: file
1563                .editor
1564                .diff_modify_collision_fg
1565                .clone()
1566                .map(|c| c.into()),
1567            tab_active_fg: file.ui.tab_active_fg.into(),
1568            tab_active_bg: file.ui.tab_active_bg.into(),
1569            tab_inactive_fg: file.ui.tab_inactive_fg.into(),
1570            tab_inactive_bg: file.ui.tab_inactive_bg.into(),
1571            tab_separator_bg: file.ui.tab_separator_bg.into(),
1572            tab_close_hover_fg: file.ui.tab_close_hover_fg.into(),
1573            tab_hover_bg: file.ui.tab_hover_bg.into(),
1574            menu_bg: file.ui.menu_bg.into(),
1575            menu_fg: file.ui.menu_fg.into(),
1576            menu_active_bg: file.ui.menu_active_bg.into(),
1577            menu_active_fg: file.ui.menu_active_fg.into(),
1578            menu_dropdown_bg: file.ui.menu_dropdown_bg.into(),
1579            menu_dropdown_fg: file.ui.menu_dropdown_fg.into(),
1580            menu_highlight_bg: file.ui.menu_highlight_bg.into(),
1581            menu_highlight_fg: file.ui.menu_highlight_fg.into(),
1582            menu_border_fg: file.ui.menu_border_fg.into(),
1583            menu_separator_fg: file.ui.menu_separator_fg.into(),
1584            menu_hover_bg: file.ui.menu_hover_bg.into(),
1585            menu_hover_fg: file.ui.menu_hover_fg.into(),
1586            menu_disabled_fg: file.ui.menu_disabled_fg.into(),
1587            menu_disabled_bg: file.ui.menu_disabled_bg.into(),
1588            status_bar_fg: file.ui.status_bar_fg.clone().into(),
1589            status_bar_bg: file.ui.status_bar_bg.clone().into(),
1590            status_palette_fg: file
1591                .ui
1592                .status_palette_fg
1593                .clone()
1594                .map(|c| c.into())
1595                .unwrap_or_else(|| file.ui.status_bar_fg.clone().into()),
1596            status_palette_bg: file
1597                .ui
1598                .status_palette_bg
1599                .clone()
1600                .map(|c| c.into())
1601                .unwrap_or_else(|| file.ui.status_bar_bg.clone().into()),
1602            status_separator_fg: file
1603                .ui
1604                .status_separator_fg
1605                .clone()
1606                .map(|c| c.into())
1607                .unwrap_or_else(|| file.ui.status_bar_fg.clone().into()),
1608            status_separator_bg: file
1609                .ui
1610                .status_separator_bg
1611                .clone()
1612                .map(|c| c.into())
1613                .unwrap_or_else(|| file.ui.status_bar_bg.clone().into()),
1614            status_lsp_on_fg: file
1615                .ui
1616                .status_lsp_on_fg
1617                .clone()
1618                .map(|c| c.into())
1619                .unwrap_or_else(|| file.ui.status_bar_fg.clone().into()),
1620            status_lsp_on_bg: file
1621                .ui
1622                .status_lsp_on_bg
1623                .clone()
1624                .map(|c| c.into())
1625                .unwrap_or_else(|| file.ui.status_bar_bg.clone().into()),
1626            status_lsp_actionable_fg: file
1627                .ui
1628                .status_lsp_actionable_fg
1629                .clone()
1630                .map(|c| c.into())
1631                .unwrap_or_else(|| file.ui.status_warning_indicator_fg.clone().into()),
1632            status_lsp_actionable_bg: file
1633                .ui
1634                .status_lsp_actionable_bg
1635                .clone()
1636                .map(|c| c.into())
1637                .unwrap_or_else(|| file.ui.status_warning_indicator_bg.clone().into()),
1638            prompt_fg: file.ui.prompt_fg.into(),
1639            prompt_bg: file.ui.prompt_bg.into(),
1640            prompt_selection_fg: file.ui.prompt_selection_fg.into(),
1641            prompt_selection_bg: file.ui.prompt_selection_bg.into(),
1642            popup_border_fg: file.ui.popup_border_fg.into(),
1643            popup_bg: file.ui.popup_bg.into(),
1644            popup_selection_bg: file.ui.popup_selection_bg.into(),
1645            popup_selection_fg: file.ui.popup_selection_fg.into(),
1646            popup_text_fg: file.ui.popup_text_fg.clone().into(),
1647            text_input_selection_bg: file.ui.text_input_selection_bg.into(),
1648            suggestion_bg: file.ui.suggestion_bg.into(),
1649            suggestion_fg: file
1650                .ui
1651                .suggestion_fg
1652                .clone()
1653                .map(|c| c.into())
1654                .unwrap_or_else(|| file.ui.popup_text_fg.clone().into()),
1655            suggestion_selected_bg: file.ui.suggestion_selected_bg.into(),
1656            help_bg: file.ui.help_bg.into(),
1657            help_fg: file.ui.help_fg.into(),
1658            help_key_fg: file.ui.help_key_fg.into(),
1659            help_separator_fg: file.ui.help_separator_fg.into(),
1660            help_indicator_fg: file.ui.help_indicator_fg.into(),
1661            help_indicator_bg: file.ui.help_indicator_bg.into(),
1662            inline_code_bg: file.ui.inline_code_bg.into(),
1663            split_separator_fg: file.ui.split_separator_fg.into(),
1664            split_separator_hover_fg: file.ui.split_separator_hover_fg.into(),
1665            scrollbar_track_fg: file.ui.scrollbar_track_fg.into(),
1666            scrollbar_thumb_fg: file.ui.scrollbar_thumb_fg.into(),
1667            scrollbar_track_hover_fg: file.ui.scrollbar_track_hover_fg.into(),
1668            scrollbar_thumb_hover_fg: file.ui.scrollbar_thumb_hover_fg.into(),
1669            compose_margin_bg: file.ui.compose_margin_bg.into(),
1670            semantic_highlight_bg: file.ui.semantic_highlight_bg.into(),
1671            semantic_highlight_modifier: file
1672                .ui
1673                .semantic_highlight_modifier
1674                .as_ref()
1675                .map(Modifier::from)
1676                .unwrap_or(Modifier::empty()),
1677            terminal_bg: file.ui.terminal_bg.into(),
1678            terminal_fg: file.ui.terminal_fg.into(),
1679            status_warning_indicator_bg: file.ui.status_warning_indicator_bg.into(),
1680            status_warning_indicator_fg: file.ui.status_warning_indicator_fg.into(),
1681            status_error_indicator_bg: file.ui.status_error_indicator_bg.into(),
1682            status_error_indicator_fg: file.ui.status_error_indicator_fg.into(),
1683            status_warning_indicator_hover_bg: file.ui.status_warning_indicator_hover_bg.into(),
1684            status_warning_indicator_hover_fg: file.ui.status_warning_indicator_hover_fg.into(),
1685            status_error_indicator_hover_bg: file.ui.status_error_indicator_hover_bg.into(),
1686            status_error_indicator_hover_fg: file.ui.status_error_indicator_hover_fg.into(),
1687            tab_drop_zone_bg: file.ui.tab_drop_zone_bg.into(),
1688            tab_drop_zone_border: file.ui.tab_drop_zone_border.into(),
1689            settings_selected_bg: file.ui.settings_selected_bg.into(),
1690            settings_selected_fg: file.ui.settings_selected_fg.into(),
1691            file_status_added_fg: file
1692                .ui
1693                .file_status_added_fg
1694                .map(|c| c.into())
1695                .unwrap_or_else(|| file.diagnostic.info_fg.clone().into()),
1696            file_status_modified_fg: file
1697                .ui
1698                .file_status_modified_fg
1699                .map(|c| c.into())
1700                .unwrap_or_else(|| file.diagnostic.warning_fg.clone().into()),
1701            file_status_deleted_fg: file
1702                .ui
1703                .file_status_deleted_fg
1704                .map(|c| c.into())
1705                .unwrap_or_else(|| file.diagnostic.error_fg.clone().into()),
1706            file_status_renamed_fg: file
1707                .ui
1708                .file_status_renamed_fg
1709                .map(|c| c.into())
1710                .unwrap_or_else(|| file.diagnostic.info_fg.clone().into()),
1711            file_status_untracked_fg: file
1712                .ui
1713                .file_status_untracked_fg
1714                .map(|c| c.into())
1715                .unwrap_or_else(|| file.diagnostic.hint_fg.clone().into()),
1716            file_status_conflicted_fg: file
1717                .ui
1718                .file_status_conflicted_fg
1719                .map(|c| c.into())
1720                .unwrap_or_else(|| file.diagnostic.error_fg.clone().into()),
1721            search_match_bg: file.search.match_bg.into(),
1722            search_match_fg: file.search.match_fg.into(),
1723            search_label_bg: file.search.label_bg.into(),
1724            search_label_fg: file.search.label_fg.into(),
1725            diagnostic_error_fg: file.diagnostic.error_fg.into(),
1726            diagnostic_error_bg: file.diagnostic.error_bg.into(),
1727            diagnostic_warning_fg: file.diagnostic.warning_fg.into(),
1728            diagnostic_warning_bg: file.diagnostic.warning_bg.into(),
1729            diagnostic_info_fg: file.diagnostic.info_fg.into(),
1730            diagnostic_info_bg: file.diagnostic.info_bg.into(),
1731            diagnostic_hint_fg: file.diagnostic.hint_fg.into(),
1732            diagnostic_hint_bg: file.diagnostic.hint_bg.into(),
1733            syntax_keyword: file.syntax.keyword.into(),
1734            syntax_string: file.syntax.string.into(),
1735            syntax_comment: file.syntax.comment.into(),
1736            syntax_function: file.syntax.function.into(),
1737            syntax_type: file.syntax.type_.into(),
1738            syntax_variable: file.syntax.variable.into(),
1739            syntax_variable_builtin: file.syntax.variable_builtin.into(),
1740            syntax_constant: file.syntax.constant.into(),
1741            syntax_operator: file.syntax.operator.into(),
1742            syntax_punctuation_bracket: file.syntax.punctuation_bracket.into(),
1743            syntax_punctuation_delimiter: file.syntax.punctuation_delimiter.into(),
1744        }
1745    }
1746}
1747
1748impl From<Theme> for ThemeFile {
1749    fn from(theme: Theme) -> Self {
1750        Self {
1751            name: theme.name,
1752            // A round-tripped `Theme` is already fully resolved — no further
1753            // inheritance is needed when serializing back out.
1754            extends: None,
1755            editor: EditorColors {
1756                bg: theme.editor_bg.into(),
1757                fg: theme.editor_fg.into(),
1758                cursor: theme.cursor.into(),
1759                inactive_cursor: theme.inactive_cursor.into(),
1760                selection_bg: theme.selection_bg.into(),
1761                selection_modifier: if theme.selection_modifier.is_empty() {
1762                    None
1763                } else {
1764                    Some(theme.selection_modifier.into())
1765                },
1766                current_line_bg: theme.current_line_bg.into(),
1767                line_number_fg: theme.line_number_fg.into(),
1768                line_number_bg: theme.line_number_bg.into(),
1769                diff_add_bg: theme.diff_add_bg.into(),
1770                diff_remove_bg: theme.diff_remove_bg.into(),
1771                diff_add_highlight_bg: Some(theme.diff_add_highlight_bg.into()),
1772                diff_remove_highlight_bg: Some(theme.diff_remove_highlight_bg.into()),
1773                diff_modify_bg: theme.diff_modify_bg.into(),
1774                diff_add_collision_fg: theme.diff_add_collision_fg.map(|c| c.into()),
1775                diff_remove_collision_fg: theme.diff_remove_collision_fg.map(|c| c.into()),
1776                diff_modify_collision_fg: theme.diff_modify_collision_fg.map(|c| c.into()),
1777                ruler_bg: theme.ruler_bg.into(),
1778                indentation_guide_fg: Some(theme.indentation_guide_fg.into()),
1779                whitespace_indicator_fg: theme.whitespace_indicator_fg.into(),
1780                bracket_match_fg: theme.bracket_match_fg.into(),
1781                bracket_rainbow_1: theme.bracket_rainbow_1.into(),
1782                bracket_rainbow_2: theme.bracket_rainbow_2.into(),
1783                bracket_rainbow_3: theme.bracket_rainbow_3.into(),
1784                bracket_rainbow_4: theme.bracket_rainbow_4.into(),
1785                bracket_rainbow_5: theme.bracket_rainbow_5.into(),
1786                bracket_rainbow_6: theme.bracket_rainbow_6.into(),
1787                after_eof_bg: Some(theme.after_eof_bg.into()),
1788            },
1789            ui: UiColors {
1790                tab_active_fg: theme.tab_active_fg.into(),
1791                tab_active_bg: theme.tab_active_bg.into(),
1792                tab_inactive_fg: theme.tab_inactive_fg.into(),
1793                tab_inactive_bg: theme.tab_inactive_bg.into(),
1794                tab_separator_bg: theme.tab_separator_bg.into(),
1795                tab_close_hover_fg: theme.tab_close_hover_fg.into(),
1796                tab_hover_bg: theme.tab_hover_bg.into(),
1797                menu_bg: theme.menu_bg.into(),
1798                menu_fg: theme.menu_fg.into(),
1799                menu_active_bg: theme.menu_active_bg.into(),
1800                menu_active_fg: theme.menu_active_fg.into(),
1801                menu_dropdown_bg: theme.menu_dropdown_bg.into(),
1802                menu_dropdown_fg: theme.menu_dropdown_fg.into(),
1803                menu_highlight_bg: theme.menu_highlight_bg.into(),
1804                menu_highlight_fg: theme.menu_highlight_fg.into(),
1805                menu_border_fg: theme.menu_border_fg.into(),
1806                menu_separator_fg: theme.menu_separator_fg.into(),
1807                menu_hover_bg: theme.menu_hover_bg.into(),
1808                menu_hover_fg: theme.menu_hover_fg.into(),
1809                menu_disabled_fg: theme.menu_disabled_fg.into(),
1810                menu_disabled_bg: theme.menu_disabled_bg.into(),
1811                status_bar_fg: theme.status_bar_fg.into(),
1812                status_bar_bg: theme.status_bar_bg.into(),
1813                status_palette_fg: Some(theme.status_palette_fg.into()),
1814                status_palette_bg: Some(theme.status_palette_bg.into()),
1815                status_separator_fg: Some(theme.status_separator_fg.into()),
1816                status_separator_bg: Some(theme.status_separator_bg.into()),
1817                status_lsp_on_fg: Some(theme.status_lsp_on_fg.into()),
1818                status_lsp_on_bg: Some(theme.status_lsp_on_bg.into()),
1819                status_lsp_actionable_fg: Some(theme.status_lsp_actionable_fg.into()),
1820                status_lsp_actionable_bg: Some(theme.status_lsp_actionable_bg.into()),
1821                prompt_fg: theme.prompt_fg.into(),
1822                prompt_bg: theme.prompt_bg.into(),
1823                prompt_selection_fg: theme.prompt_selection_fg.into(),
1824                prompt_selection_bg: theme.prompt_selection_bg.into(),
1825                popup_border_fg: theme.popup_border_fg.into(),
1826                popup_bg: theme.popup_bg.into(),
1827                popup_selection_bg: theme.popup_selection_bg.into(),
1828                popup_selection_fg: theme.popup_selection_fg.into(),
1829                popup_text_fg: theme.popup_text_fg.into(),
1830                text_input_selection_bg: theme.text_input_selection_bg.into(),
1831                suggestion_bg: theme.suggestion_bg.into(),
1832                suggestion_fg: Some(theme.suggestion_fg.into()),
1833                suggestion_selected_bg: theme.suggestion_selected_bg.into(),
1834                help_bg: theme.help_bg.into(),
1835                help_fg: theme.help_fg.into(),
1836                help_key_fg: theme.help_key_fg.into(),
1837                help_separator_fg: theme.help_separator_fg.into(),
1838                help_indicator_fg: theme.help_indicator_fg.into(),
1839                help_indicator_bg: theme.help_indicator_bg.into(),
1840                inline_code_bg: theme.inline_code_bg.into(),
1841                split_separator_fg: theme.split_separator_fg.into(),
1842                split_separator_hover_fg: theme.split_separator_hover_fg.into(),
1843                scrollbar_track_fg: theme.scrollbar_track_fg.into(),
1844                scrollbar_thumb_fg: theme.scrollbar_thumb_fg.into(),
1845                scrollbar_track_hover_fg: theme.scrollbar_track_hover_fg.into(),
1846                scrollbar_thumb_hover_fg: theme.scrollbar_thumb_hover_fg.into(),
1847                compose_margin_bg: theme.compose_margin_bg.into(),
1848                semantic_highlight_bg: theme.semantic_highlight_bg.into(),
1849                semantic_highlight_modifier: if theme.semantic_highlight_modifier.is_empty() {
1850                    None
1851                } else {
1852                    Some(theme.semantic_highlight_modifier.into())
1853                },
1854                terminal_bg: theme.terminal_bg.into(),
1855                terminal_fg: theme.terminal_fg.into(),
1856                status_warning_indicator_bg: theme.status_warning_indicator_bg.into(),
1857                status_warning_indicator_fg: theme.status_warning_indicator_fg.into(),
1858                status_error_indicator_bg: theme.status_error_indicator_bg.into(),
1859                status_error_indicator_fg: theme.status_error_indicator_fg.into(),
1860                status_warning_indicator_hover_bg: theme.status_warning_indicator_hover_bg.into(),
1861                status_warning_indicator_hover_fg: theme.status_warning_indicator_hover_fg.into(),
1862                status_error_indicator_hover_bg: theme.status_error_indicator_hover_bg.into(),
1863                status_error_indicator_hover_fg: theme.status_error_indicator_hover_fg.into(),
1864                tab_drop_zone_bg: theme.tab_drop_zone_bg.into(),
1865                tab_drop_zone_border: theme.tab_drop_zone_border.into(),
1866                settings_selected_bg: theme.settings_selected_bg.into(),
1867                settings_selected_fg: theme.settings_selected_fg.into(),
1868                file_status_added_fg: Some(theme.file_status_added_fg.into()),
1869                file_status_modified_fg: Some(theme.file_status_modified_fg.into()),
1870                file_status_deleted_fg: Some(theme.file_status_deleted_fg.into()),
1871                file_status_renamed_fg: Some(theme.file_status_renamed_fg.into()),
1872                file_status_untracked_fg: Some(theme.file_status_untracked_fg.into()),
1873                file_status_conflicted_fg: Some(theme.file_status_conflicted_fg.into()),
1874            },
1875            search: SearchColors {
1876                match_bg: theme.search_match_bg.into(),
1877                match_fg: theme.search_match_fg.into(),
1878                label_bg: theme.search_label_bg.into(),
1879                label_fg: theme.search_label_fg.into(),
1880            },
1881            diagnostic: DiagnosticColors {
1882                error_fg: theme.diagnostic_error_fg.into(),
1883                error_bg: theme.diagnostic_error_bg.into(),
1884                warning_fg: theme.diagnostic_warning_fg.into(),
1885                warning_bg: theme.diagnostic_warning_bg.into(),
1886                info_fg: theme.diagnostic_info_fg.into(),
1887                info_bg: theme.diagnostic_info_bg.into(),
1888                hint_fg: theme.diagnostic_hint_fg.into(),
1889                hint_bg: theme.diagnostic_hint_bg.into(),
1890            },
1891            syntax: SyntaxColors {
1892                keyword: theme.syntax_keyword.into(),
1893                string: theme.syntax_string.into(),
1894                comment: theme.syntax_comment.into(),
1895                function: theme.syntax_function.into(),
1896                type_: theme.syntax_type.into(),
1897                variable: theme.syntax_variable.into(),
1898                variable_builtin: theme.syntax_variable_builtin.into(),
1899                constant: theme.syntax_constant.into(),
1900                operator: theme.syntax_operator.into(),
1901                punctuation_bracket: theme.syntax_punctuation_bracket.into(),
1902                punctuation_delimiter: theme.syntax_punctuation_delimiter.into(),
1903            },
1904        }
1905    }
1906}
1907
1908/// Resolve the base theme that a parsed `ThemeFile` should be layered on top of.
1909///
1910/// See [`ThemeFile`] for the resolution order. Returns an error only when
1911/// `extends` references a base that does not exist; the no-info-at-all case
1912/// quietly falls through to the per-field hardcoded defaults so a theme of
1913/// `{"name": "x"}` keeps working.
1914fn resolve_base_theme(theme_file: &ThemeFile, raw: &serde_json::Value) -> Result<Theme, String> {
1915    // 1. Explicit `extends`.
1916    if let Some(extends) = theme_file.extends.as_deref() {
1917        let name = extends.strip_prefix("builtin://").unwrap_or(extends);
1918        return Theme::load_builtin(name).ok_or_else(|| {
1919            let available: Vec<&str> = BUILTIN_THEMES.iter().map(|t| t.name).collect();
1920            format!(
1921                "theme `extends: {:?}` does not match any built-in theme. \
1922                 Available: {}. \
1923                 Inheriting from other user themes is not yet supported.",
1924                extends,
1925                available.join(", ")
1926            )
1927        });
1928    }
1929
1930    // 2. Auto-infer from explicit `editor.bg` luminance. We deliberately read
1931    //    the *raw* JSON here instead of `theme_file.editor.bg` — the typed
1932    //    struct fills in a default for `bg` even when the user didn't write
1933    //    one, and inferring a base from a default we ourselves invented would
1934    //    be circular.
1935    if let Some(bg) = raw
1936        .get("editor")
1937        .and_then(|e| e.get("bg"))
1938        .cloned()
1939        .and_then(|v| serde_json::from_value::<ColorDef>(v).ok())
1940    {
1941        let color: Color = bg.into();
1942        if let Some((r, g, b)) = color_to_rgb(color) {
1943            let lum = relative_luminance(r, g, b);
1944            let base_name = if lum > 0.5 { THEME_LIGHT } else { THEME_DARK };
1945            if let Some(base) = Theme::load_builtin(base_name) {
1946                return Ok(base);
1947            }
1948        }
1949    }
1950
1951    // 3. Fallback: per-field hardcoded defaults via the existing typed path.
1952    Ok(theme_file.clone().into())
1953}
1954
1955/// Compute sRGB relative luminance (ITU-R BT.709) for an RGB triple in 0..=255.
1956/// Used for picking a light vs dark base when the user didn't ask for one.
1957fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
1958    0.2126 * (r as f64 / 255.0) + 0.7152 * (g as f64 / 255.0) + 0.0722 * (b as f64 / 255.0)
1959}
1960
1961/// Walk the user-supplied JSON and overlay every explicitly-set leaf onto the
1962/// base theme. Reuses [`Theme::resolve_theme_key_mut`] so the override surface
1963/// is exactly the surface the rest of the editor already knows how to address;
1964/// unknown keys are silently ignored, matching `override_colors` semantics.
1965fn apply_theme_overrides(theme: &mut Theme, theme_file: &ThemeFile, raw: &serde_json::Value) {
1966    // Name always comes from the user file — that's the theme's identity.
1967    theme.name = theme_file.name.clone();
1968
1969    for section in ["editor", "ui", "search", "diagnostic", "syntax"] {
1970        let Some(obj) = raw.get(section).and_then(|v| v.as_object()) else {
1971            continue;
1972        };
1973        for (field, value) in obj {
1974            // Optional `Option<ColorDef>` fields encode `null` as JSON null.
1975            // Treat that as "no override," not "set to default."
1976            if value.is_null() {
1977                continue;
1978            }
1979            let key = format!("{}.{}", section, field);
1980            if let Ok(color_def) = serde_json::from_value::<ColorDef>(value.clone()) {
1981                if let Some(slot) = theme.resolve_theme_key_mut(&key) {
1982                    *slot = color_def.into();
1983                }
1984            }
1985        }
1986    }
1987
1988    if raw
1989        .get("editor")
1990        .and_then(|v| v.as_object())
1991        .is_some_and(|editor| {
1992            editor.contains_key("whitespace_indicator_fg")
1993                && !editor.contains_key("indentation_guide_fg")
1994        })
1995    {
1996        theme.indentation_guide_fg = theme.whitespace_indicator_fg;
1997    }
1998}
1999
2000impl Theme {
2001    /// Returns `true` when the theme has a light background.
2002    ///
2003    /// Uses the relative luminance of `editor_bg` (perceived brightness).
2004    /// A threshold of 0.5 separates dark from light; for `Color::Reset` or
2005    /// unresolvable colors, falls back to `false` (dark).
2006    pub fn is_light(&self) -> bool {
2007        color_to_rgb(self.editor_bg)
2008            .map(|(r, g, b)| relative_luminance(r, g, b) > 0.5)
2009            .unwrap_or(false)
2010    }
2011
2012    /// Load a builtin theme by name (no I/O, uses embedded JSON).
2013    pub fn load_builtin(name: &str) -> Option<Self> {
2014        BUILTIN_THEMES
2015            .iter()
2016            .find(|t| t.name == name)
2017            .and_then(|t| serde_json::from_str::<ThemeFile>(t.json).ok())
2018            .map(|tf| tf.into())
2019    }
2020
2021    /// Parse theme from JSON string (no I/O).
2022    ///
2023    /// Supports the inheritance model documented on [`ThemeFile`]: an explicit
2024    /// `extends` chooses the base; otherwise the relative luminance of an
2025    /// explicit `editor.bg` picks `builtin://light` vs `builtin://dark`;
2026    /// otherwise the per-field hardcoded defaults apply. Every leaf the user
2027    /// JSON specifies overrides the corresponding field on the base — the
2028    /// override walk uses the same `resolve_theme_key_mut` machinery as
2029    /// `override_colors`, so the supported set of keys stays in lock-step.
2030    pub fn from_json(json: &str) -> Result<Self, String> {
2031        // Dual-parse: the typed `ThemeFile` validates the schema and gives us
2032        // `name` / `extends` cheaply; the raw `Value` tells us *which* fields
2033        // the user actually specified, which we cannot recover from the typed
2034        // struct because every field has a serde default.
2035        let raw: serde_json::Value =
2036            serde_json::from_str(json).map_err(|e| format!("Failed to parse theme JSON: {}", e))?;
2037        let theme_file: ThemeFile = serde_json::from_value(raw.clone())
2038            .map_err(|e| format!("Failed to parse theme: {}", e))?;
2039
2040        let mut theme = resolve_base_theme(&theme_file, &raw)?;
2041        apply_theme_overrides(&mut theme, &theme_file, &raw);
2042        Ok(theme)
2043    }
2044
2045    /// SGR text-attribute modifier associated with a bg theme key.
2046    ///
2047    /// Lets overlay-driven highlights (e.g. word-under-cursor via
2048    /// `ui.semantic_highlight_bg`, selection via `editor.selection_bg`)
2049    /// pick up the same modifier the theme would apply directly when
2050    /// painting that region, so a `terminal` theme's `["reversed"]`
2051    /// selection works whether the cells go through `char_style` or
2052    /// the overlay pipeline. Unknown keys return `Modifier::empty()`.
2053    pub fn modifier_for_bg_key(&self, key: &str) -> Modifier {
2054        match key {
2055            "editor.selection_bg" => self.selection_modifier,
2056            "ui.semantic_highlight_bg" => self.semantic_highlight_modifier,
2057            _ => Modifier::empty(),
2058        }
2059    }
2060}
2061
2062/// Split a `"section.field"` theme key into its two components, returning
2063/// `None` when it is not exactly two dot-separated parts.
2064fn split_theme_key(key: &str) -> Option<(&str, &str)> {
2065    let (section, field) = key.split_once('.')?;
2066    if field.contains('.') {
2067        return None;
2068    }
2069    Some((section, field))
2070}
2071
2072/// Generate the read and write theme-key resolvers from a single key -> field
2073/// table.
2074///
2075/// [`Theme::resolve_theme_key`] (by value) and [`Theme::resolve_theme_key_mut`]
2076/// (by `&mut`) map exactly the same `"section.field"` keys to the same `Theme`
2077/// fields. They used to be two hand-maintained ~150-line `match`es that had to
2078/// be kept "in lock-step" by eye — and had already drifted into different
2079/// orderings. Deriving both from one table makes divergence impossible by
2080/// construction. `color` entries are plain `Color` fields; `opt` entries are
2081/// `Option<Color>` fields, which are only writable once the theme JSON has set
2082/// them (so the mutable resolver yields `None` while they are unset).
2083macro_rules! theme_color_keys {
2084    (
2085        $(
2086            $section:literal => {
2087                $( $field_key:literal => $kind:tt $field:ident ),* $(,)?
2088            }
2089        ),* $(,)?
2090    ) => {
2091        impl Theme {
2092            /// Resolve a `"section.field"` theme key to its `Color` value.
2093            ///
2094            /// Returns `None` for an unrecognized key, or a malformed key that
2095            /// is not exactly two dot-separated parts.
2096            pub fn resolve_theme_key(&self, key: &str) -> Option<Color> {
2097                let (section, field) = split_theme_key(key)?;
2098                match section {
2099                    $(
2100                        $section => match field {
2101                            $( $field_key => theme_color_keys!(@get self, $kind $field), )*
2102                            _ => None,
2103                        },
2104                    )*
2105                    _ => None,
2106                }
2107            }
2108
2109            /// Mutable companion to [`Theme::resolve_theme_key`]. Generated
2110            /// from the same table, so the readable and writable key sets stay
2111            /// identical by construction.
2112            pub fn resolve_theme_key_mut(&mut self, key: &str) -> Option<&mut Color> {
2113                let (section, field) = split_theme_key(key)?;
2114                match section {
2115                    $(
2116                        $section => match field {
2117                            $( $field_key => theme_color_keys!(@get_mut self, $kind $field), )*
2118                            _ => None,
2119                        },
2120                    )*
2121                    _ => None,
2122                }
2123            }
2124        }
2125    };
2126
2127    // Per-field accessors. `color` fields are `Color`; `opt` fields are
2128    // `Option<Color>` and only resolve to a slot once set.
2129    (@get $self:ident, color $field:ident) => { Some($self.$field) };
2130    (@get $self:ident, opt $field:ident) => { $self.$field };
2131    (@get_mut $self:ident, color $field:ident) => { Some(&mut $self.$field) };
2132    (@get_mut $self:ident, opt $field:ident) => { $self.$field.as_mut() };
2133}
2134
2135theme_color_keys! {
2136    "editor" => {
2137        "after_eof_bg" => color after_eof_bg,
2138        "bg" => color editor_bg,
2139        "current_line_bg" => color current_line_bg,
2140        "cursor" => color cursor,
2141        "diff_add_bg" => color diff_add_bg,
2142        "diff_add_collision_fg" => opt diff_add_collision_fg,
2143        "diff_add_highlight_bg" => color diff_add_highlight_bg,
2144        "diff_modify_bg" => color diff_modify_bg,
2145        "diff_modify_collision_fg" => opt diff_modify_collision_fg,
2146        "diff_remove_bg" => color diff_remove_bg,
2147        "diff_remove_collision_fg" => opt diff_remove_collision_fg,
2148        "diff_remove_highlight_bg" => color diff_remove_highlight_bg,
2149        "fg" => color editor_fg,
2150        "inactive_cursor" => color inactive_cursor,
2151        "indentation_guide_fg" => color indentation_guide_fg,
2152        "line_number_bg" => color line_number_bg,
2153        "line_number_fg" => color line_number_fg,
2154        "ruler_bg" => color ruler_bg,
2155        "selection_bg" => color selection_bg,
2156        "whitespace_indicator_fg" => color whitespace_indicator_fg,
2157        "bracket_match_fg" => color bracket_match_fg,
2158        "bracket_rainbow_1" => color bracket_rainbow_1,
2159        "bracket_rainbow_2" => color bracket_rainbow_2,
2160        "bracket_rainbow_3" => color bracket_rainbow_3,
2161        "bracket_rainbow_4" => color bracket_rainbow_4,
2162        "bracket_rainbow_5" => color bracket_rainbow_5,
2163        "bracket_rainbow_6" => color bracket_rainbow_6,
2164    },
2165    "ui" => {
2166        "compose_margin_bg" => color compose_margin_bg,
2167        "file_status_added_fg" => color file_status_added_fg,
2168        "file_status_conflicted_fg" => color file_status_conflicted_fg,
2169        "file_status_deleted_fg" => color file_status_deleted_fg,
2170        "file_status_modified_fg" => color file_status_modified_fg,
2171        "file_status_renamed_fg" => color file_status_renamed_fg,
2172        "file_status_untracked_fg" => color file_status_untracked_fg,
2173        "help_bg" => color help_bg,
2174        "help_fg" => color help_fg,
2175        "help_indicator_bg" => color help_indicator_bg,
2176        "help_indicator_fg" => color help_indicator_fg,
2177        "help_key_fg" => color help_key_fg,
2178        "help_separator_fg" => color help_separator_fg,
2179        "inline_code_bg" => color inline_code_bg,
2180        "menu_active_bg" => color menu_active_bg,
2181        "menu_active_fg" => color menu_active_fg,
2182        "menu_bg" => color menu_bg,
2183        "menu_border_fg" => color menu_border_fg,
2184        "menu_disabled_bg" => color menu_disabled_bg,
2185        "menu_disabled_fg" => color menu_disabled_fg,
2186        "menu_dropdown_bg" => color menu_dropdown_bg,
2187        "menu_dropdown_fg" => color menu_dropdown_fg,
2188        "menu_fg" => color menu_fg,
2189        "menu_highlight_bg" => color menu_highlight_bg,
2190        "menu_highlight_fg" => color menu_highlight_fg,
2191        "menu_hover_bg" => color menu_hover_bg,
2192        "menu_hover_fg" => color menu_hover_fg,
2193        "menu_separator_fg" => color menu_separator_fg,
2194        "popup_bg" => color popup_bg,
2195        "popup_border_fg" => color popup_border_fg,
2196        "popup_selection_bg" => color popup_selection_bg,
2197        "popup_selection_fg" => color popup_selection_fg,
2198        "popup_text_fg" => color popup_text_fg,
2199        "prompt_bg" => color prompt_bg,
2200        "prompt_fg" => color prompt_fg,
2201        "prompt_selection_bg" => color prompt_selection_bg,
2202        "prompt_selection_fg" => color prompt_selection_fg,
2203        "scrollbar_thumb_fg" => color scrollbar_thumb_fg,
2204        "scrollbar_thumb_hover_fg" => color scrollbar_thumb_hover_fg,
2205        "scrollbar_track_fg" => color scrollbar_track_fg,
2206        "scrollbar_track_hover_fg" => color scrollbar_track_hover_fg,
2207        "semantic_highlight_bg" => color semantic_highlight_bg,
2208        "settings_selected_bg" => color settings_selected_bg,
2209        "settings_selected_fg" => color settings_selected_fg,
2210        "split_separator_fg" => color split_separator_fg,
2211        "split_separator_hover_fg" => color split_separator_hover_fg,
2212        "status_bar_bg" => color status_bar_bg,
2213        "status_bar_fg" => color status_bar_fg,
2214        "status_error_indicator_bg" => color status_error_indicator_bg,
2215        "status_error_indicator_fg" => color status_error_indicator_fg,
2216        "status_error_indicator_hover_bg" => color status_error_indicator_hover_bg,
2217        "status_error_indicator_hover_fg" => color status_error_indicator_hover_fg,
2218        "status_lsp_actionable_bg" => color status_lsp_actionable_bg,
2219        "status_lsp_actionable_fg" => color status_lsp_actionable_fg,
2220        "status_lsp_on_bg" => color status_lsp_on_bg,
2221        "status_lsp_on_fg" => color status_lsp_on_fg,
2222        "status_palette_bg" => color status_palette_bg,
2223        "status_palette_fg" => color status_palette_fg,
2224        "status_separator_bg" => color status_separator_bg,
2225        "status_separator_fg" => color status_separator_fg,
2226        "status_warning_indicator_bg" => color status_warning_indicator_bg,
2227        "status_warning_indicator_fg" => color status_warning_indicator_fg,
2228        "status_warning_indicator_hover_bg" => color status_warning_indicator_hover_bg,
2229        "status_warning_indicator_hover_fg" => color status_warning_indicator_hover_fg,
2230        "suggestion_bg" => color suggestion_bg,
2231        "suggestion_fg" => color suggestion_fg,
2232        "suggestion_selected_bg" => color suggestion_selected_bg,
2233        "tab_active_bg" => color tab_active_bg,
2234        "tab_active_fg" => color tab_active_fg,
2235        "tab_close_hover_fg" => color tab_close_hover_fg,
2236        "tab_drop_zone_bg" => color tab_drop_zone_bg,
2237        "tab_drop_zone_border" => color tab_drop_zone_border,
2238        "tab_hover_bg" => color tab_hover_bg,
2239        "tab_inactive_bg" => color tab_inactive_bg,
2240        "tab_inactive_fg" => color tab_inactive_fg,
2241        "tab_separator_bg" => color tab_separator_bg,
2242        "terminal_bg" => color terminal_bg,
2243        "terminal_fg" => color terminal_fg,
2244        "text_input_selection_bg" => color text_input_selection_bg,
2245    },
2246    "syntax" => {
2247        "comment" => color syntax_comment,
2248        "constant" => color syntax_constant,
2249        "function" => color syntax_function,
2250        "keyword" => color syntax_keyword,
2251        "operator" => color syntax_operator,
2252        "punctuation_bracket" => color syntax_punctuation_bracket,
2253        "punctuation_delimiter" => color syntax_punctuation_delimiter,
2254        "string" => color syntax_string,
2255        "type" => color syntax_type,
2256        "variable" => color syntax_variable,
2257        "variable_builtin" => color syntax_variable_builtin,
2258    },
2259    "diagnostic" => {
2260        "error_bg" => color diagnostic_error_bg,
2261        "error_fg" => color diagnostic_error_fg,
2262        "hint_bg" => color diagnostic_hint_bg,
2263        "hint_fg" => color diagnostic_hint_fg,
2264        "info_bg" => color diagnostic_info_bg,
2265        "info_fg" => color diagnostic_info_fg,
2266        "warning_bg" => color diagnostic_warning_bg,
2267        "warning_fg" => color diagnostic_warning_fg,
2268    },
2269    "search" => {
2270        "label_bg" => color search_label_bg,
2271        "label_fg" => color search_label_fg,
2272        "match_bg" => color search_match_bg,
2273        "match_fg" => color search_match_fg,
2274    },
2275}
2276
2277impl Theme {
2278    /// Apply a map of `"section.field" -> Color` overrides to the running
2279    /// theme in-place. Returns the number of keys that matched a known
2280    /// theme field. Unknown keys are silently dropped so a typo in a fast
2281    /// animation loop doesn't crash the caller.
2282    pub fn override_colors<I, K>(&mut self, overrides: I) -> usize
2283    where
2284        I: IntoIterator<Item = (K, Color)>,
2285        K: AsRef<str>,
2286    {
2287        let mut applied = 0;
2288        for (key, color) in overrides {
2289            if let Some(slot) = self.resolve_theme_key_mut(key.as_ref()) {
2290                *slot = color;
2291                applied += 1;
2292            }
2293        }
2294        applied
2295    }
2296}
2297
2298// =============================================================================
2299// Theme Schema Generation for Plugin API
2300// =============================================================================
2301
2302/// Returns the raw JSON Schema for ThemeFile, generated by schemars.
2303/// The schema uses standard JSON Schema format with $ref for type references.
2304/// Plugins are responsible for parsing and resolving $ref references.
2305pub fn get_theme_schema() -> serde_json::Value {
2306    use schemars::schema_for;
2307    let schema = schema_for!(ThemeFile);
2308    serde_json::to_value(&schema).unwrap_or_default()
2309}
2310
2311/// Returns a map of built-in theme names to their JSON content.
2312pub fn get_builtin_themes() -> serde_json::Value {
2313    let mut map = serde_json::Map::new();
2314    for theme in BUILTIN_THEMES {
2315        map.insert(
2316            theme.name.to_string(),
2317            serde_json::Value::String(theme.json.to_string()),
2318        );
2319    }
2320    serde_json::Value::Object(map)
2321}
2322
2323#[cfg(test)]
2324mod tests {
2325    use super::*;
2326
2327    #[test]
2328    fn test_load_builtin_theme() {
2329        let dark = Theme::load_builtin(THEME_DARK).expect("Dark theme must exist");
2330        assert_eq!(dark.name, THEME_DARK);
2331
2332        let light = Theme::load_builtin(THEME_LIGHT).expect("Light theme must exist");
2333        assert_eq!(light.name, THEME_LIGHT);
2334
2335        let high_contrast =
2336            Theme::load_builtin(THEME_HIGH_CONTRAST).expect("High contrast theme must exist");
2337        assert_eq!(high_contrast.name, THEME_HIGH_CONTRAST);
2338
2339        let terminal = Theme::load_builtin(THEME_TERMINAL).expect("Terminal theme must exist");
2340        assert_eq!(terminal.name, THEME_TERMINAL);
2341        // The terminal theme defers to the host palette: backgrounds and
2342        // primary text use Color::Reset so the terminal's own colors
2343        // (including transparency) show through.
2344        assert_eq!(terminal.editor_bg, Color::Reset);
2345        assert_eq!(terminal.editor_fg, Color::Reset);
2346        assert_eq!(terminal.terminal_bg, Color::Reset);
2347        // Adaptive accents use SGR text attributes so they invert/emphasise
2348        // against whatever fg/bg the terminal already has.
2349        assert!(terminal.selection_modifier.contains(Modifier::REVERSED));
2350        assert!(terminal
2351            .semantic_highlight_modifier
2352            .contains(Modifier::BOLD));
2353    }
2354
2355    #[test]
2356    fn test_suggestion_fg_falls_back_and_contrasts() {
2357        // Regression: the overlay prompt (Live Grep) drew its title/input
2358        // on `suggestion_bg`/`editor_bg` but borrowed `prompt_fg` for the
2359        // text. Dracula's `prompt_fg` equals its editor background, so the
2360        // text was invisible. `suggestion_fg` is the dedicated foreground
2361        // for that surface; when a theme omits it, it falls back to
2362        // `popup_text_fg` (never `prompt_fg`).
2363        let dracula = Theme::load_builtin(THEME_DRACULA).expect("Dracula theme must exist");
2364        assert_eq!(
2365            dracula.suggestion_fg, dracula.popup_text_fg,
2366            "suggestion_fg should fall back to popup_text_fg when unset"
2367        );
2368        assert_ne!(
2369            dracula.suggestion_fg, dracula.suggestion_bg,
2370            "suggestion_fg must contrast with suggestion_bg, not vanish into it"
2371        );
2372    }
2373
2374    #[test]
2375    fn test_modifier_def_round_trip() {
2376        let cases = [
2377            (vec!["reversed"], Modifier::REVERSED),
2378            (
2379                vec!["bold", "underlined"],
2380                Modifier::BOLD | Modifier::UNDERLINED,
2381            ),
2382            (vec!["italic", "dim"], Modifier::ITALIC | Modifier::DIM),
2383            (vec!["reverse"], Modifier::REVERSED),     // alias
2384            (vec!["underline"], Modifier::UNDERLINED), // alias
2385        ];
2386        for (strs, expected) in cases {
2387            let def = ModifierDef(strs.iter().map(|s| s.to_string()).collect());
2388            let m: Modifier = (&def).into();
2389            assert_eq!(m, expected, "ModifierDef({:?}) -> Modifier", strs);
2390        }
2391    }
2392
2393    #[test]
2394    fn test_modifier_def_unknown_strings_are_dropped() {
2395        // A typo in a theme JSON shouldn't crash a render — unknown
2396        // modifier names are silently dropped.
2397        let def = ModifierDef(vec!["reversed".into(), "wibble".into(), "bold".into()]);
2398        let m: Modifier = (&def).into();
2399        assert_eq!(m, Modifier::REVERSED | Modifier::BOLD);
2400    }
2401
2402    #[test]
2403    fn test_themes_without_modifier_default_to_empty() {
2404        // Existing themes (no `*_modifier` keys in their JSON) must
2405        // resolve to Modifier::empty() — i.e. the new fields are
2406        // backward compatible and don't change rendering for old
2407        // themes.
2408        let dark = Theme::load_builtin(THEME_DARK).expect("Dark theme must exist");
2409        assert!(dark.selection_modifier.is_empty());
2410        assert!(dark.semantic_highlight_modifier.is_empty());
2411    }
2412
2413    #[test]
2414    fn test_modifier_for_bg_key_lookup() {
2415        let terminal = Theme::load_builtin(THEME_TERMINAL).expect("Terminal theme must exist");
2416        // Overlay-driven highlights pick up the same modifier the
2417        // direct-paint path uses, keyed by bg theme key.
2418        assert!(terminal
2419            .modifier_for_bg_key("editor.selection_bg")
2420            .contains(Modifier::REVERSED));
2421        assert!(terminal
2422            .modifier_for_bg_key("ui.semantic_highlight_bg")
2423            .contains(Modifier::BOLD));
2424        // Unknown / unmapped keys yield empty so we don't accidentally
2425        // tint other UI regions.
2426        assert!(terminal
2427            .modifier_for_bg_key("ui.popup_selection_bg")
2428            .is_empty());
2429        assert!(terminal.modifier_for_bg_key("nonsense").is_empty());
2430    }
2431
2432    #[test]
2433    fn test_modifier_round_trip_via_theme_file() {
2434        // Theme -> ThemeFile -> Theme preserves modifiers.
2435        let original = Theme::load_builtin(THEME_TERMINAL).expect("Terminal theme must exist");
2436        let file: ThemeFile = original.clone().into();
2437        let json = serde_json::to_string(&file).expect("serialize");
2438        let parsed: ThemeFile = serde_json::from_str(&json).expect("parse");
2439        let round_tripped: Theme = parsed.into();
2440        assert_eq!(
2441            round_tripped.selection_modifier,
2442            original.selection_modifier
2443        );
2444        assert_eq!(
2445            round_tripped.semantic_highlight_modifier,
2446            original.semantic_highlight_modifier
2447        );
2448    }
2449
2450    #[test]
2451    fn test_builtin_themes_match_schema() {
2452        for theme in BUILTIN_THEMES {
2453            let _: ThemeFile = serde_json::from_str(theme.json)
2454                .unwrap_or_else(|_| panic!("Theme '{}' does not match schema", theme.name));
2455        }
2456    }
2457
2458    #[test]
2459    fn test_from_json() {
2460        let json = r#"{"name":"test","editor":{},"ui":{},"search":{},"diagnostic":{},"syntax":{}}"#;
2461        let theme = Theme::from_json(json).expect("Should parse minimal theme");
2462        assert_eq!(theme.name, "test");
2463    }
2464
2465    /// Regression test for #1281: a user theme that follows the minimal example
2466    /// in `docs/features/themes.md` (only `name`, `editor`, `syntax` — no `ui`,
2467    /// `search`, or `diagnostic` sections) must load successfully. Before the
2468    /// fix, `serde_json::from_str::<ThemeFile>` errored with `missing field
2469    /// `ui``, the loader silently dropped the theme, and the user saw
2470    /// "Failed to load theme" in the status bar.
2471    ///
2472    /// Beyond loading, this also pins the auto-inheritance behavior: with a
2473    /// cream `editor.bg`, the unspecified UI/diagnostic colors must come from
2474    /// `builtin://light` (so the theme reads coherently end-to-end), not from
2475    /// the dark-flavored hardcoded fallbacks.
2476    #[test]
2477    fn test_minimal_user_theme_from_issue_1281_loads() {
2478        // Verbatim from https://github.com/sinelaw/fresh/issues/1281
2479        let json = r#"{
2480  "name": "gruvbox-light-orange",
2481  "editor": {
2482    "bg": [251, 241, 199],
2483    "fg": [60, 56, 54],
2484    "cursor": [254, 128, 25],
2485    "selection_bg": [213, 196, 161]
2486  },
2487  "syntax": {
2488    "keyword": [175, 58, 3],
2489    "string": [152, 151, 26],
2490    "comment": [146, 131, 116]
2491  }
2492}"#;
2493        let theme = Theme::from_json(json)
2494            .expect("Theme from issue #1281 should parse without `ui`/`search`/`diagnostic`");
2495        assert_eq!(theme.name, "gruvbox-light-orange");
2496
2497        // Explicit fields land where expected.
2498        assert_eq!(theme.editor_bg, Color::Rgb(251, 241, 199));
2499        assert_eq!(theme.editor_fg, Color::Rgb(60, 56, 54));
2500        assert_eq!(theme.cursor, Color::Rgb(254, 128, 25));
2501        assert_eq!(theme.selection_bg, Color::Rgb(213, 196, 161));
2502        assert_eq!(theme.syntax_keyword, Color::Rgb(175, 58, 3));
2503        assert_eq!(theme.syntax_string, Color::Rgb(152, 151, 26));
2504        assert_eq!(theme.syntax_comment, Color::Rgb(146, 131, 116));
2505
2506        // Auto-inheritance: cream bg → `builtin://light` is the base. The
2507        // unspecified UI/diagnostic colors should match the light builtin's
2508        // values — not the dark-flavored hardcoded fallbacks.
2509        let light = Theme::load_builtin(THEME_LIGHT).expect("light builtin");
2510        assert_eq!(
2511            theme.status_bar_fg, light.status_bar_fg,
2512            "ui.status_bar_fg should inherit from builtin://light when bg is bright"
2513        );
2514        assert_eq!(
2515            theme.diagnostic_error_fg, light.diagnostic_error_fg,
2516            "diagnostic.error_fg should inherit from builtin://light when bg is bright"
2517        );
2518        assert_eq!(
2519            theme.menu_bg, light.menu_bg,
2520            "ui.menu_bg should inherit from builtin://light when bg is bright"
2521        );
2522    }
2523
2524    /// A user theme with an explicit `extends` must inherit from that base —
2525    /// even when auto-inference would have picked something different.
2526    #[test]
2527    fn test_extends_explicit_builtin_wins_over_auto_infer() {
2528        // `editor.bg` is dark (would auto-infer `dark`), but `extends` asks
2529        // for `light`. The explicit choice must win.
2530        let json = r#"{
2531            "name": "explicit-light",
2532            "extends": "builtin://light",
2533            "editor": { "bg": [0, 0, 0] }
2534        }"#;
2535        let theme = Theme::from_json(json).expect("extends should resolve");
2536        let light = Theme::load_builtin(THEME_LIGHT).expect("light builtin");
2537
2538        // Override applied.
2539        assert_eq!(theme.editor_bg, Color::Rgb(0, 0, 0));
2540        // Unspecified fields come from the explicit base, not from auto-infer.
2541        assert_eq!(theme.menu_bg, light.menu_bg);
2542        assert_eq!(theme.tab_active_bg, light.tab_active_bg);
2543        assert_eq!(theme.diagnostic_warning_fg, light.diagnostic_warning_fg);
2544    }
2545
2546    /// Bare-name `extends` (e.g. `"dark"`) is the legacy form accepted by the
2547    /// rest of the registry (`ThemeRegistry::resolve_key`), so we accept it
2548    /// here too — being strict about a `builtin://` prefix would just be a
2549    /// papercut for users hand-writing a theme JSON.
2550    #[test]
2551    fn test_extends_bare_builtin_name_works() {
2552        let json = r#"{ "name": "x", "extends": "high-contrast" }"#;
2553        let theme = Theme::from_json(json).expect("bare-name extends should resolve");
2554        let hc = Theme::load_builtin("high-contrast").expect("hc builtin");
2555        assert_eq!(theme.menu_bg, hc.menu_bg);
2556    }
2557
2558    /// An unknown `extends` target must produce a clear error that names what
2559    /// went wrong and lists the valid alternatives — anything less leaves the
2560    /// user staring at the same opaque "Failed to load theme" message that
2561    /// motivated #1281 in the first place.
2562    #[test]
2563    fn test_extends_unknown_builtin_errors_with_helpful_message() {
2564        let json = r#"{ "name": "x", "extends": "builtin://no-such-theme" }"#;
2565        let err = Theme::from_json(json).expect_err("unknown extends must error");
2566        assert!(
2567            err.contains("no-such-theme"),
2568            "error should quote the bad value, got: {}",
2569            err
2570        );
2571        assert!(
2572            err.contains("dark") && err.contains("light"),
2573            "error should list available builtins, got: {}",
2574            err
2575        );
2576    }
2577
2578    /// Auto-inference picks `dark` for a clearly-dark `editor.bg`. Mirrors
2579    /// the light path tested in the #1281 regression so both branches stay
2580    /// honest.
2581    #[test]
2582    fn test_auto_infer_dark_base_from_dark_bg() {
2583        let json = r#"{ "name": "x", "editor": { "bg": [20, 20, 30] } }"#;
2584        let theme = Theme::from_json(json).expect("should parse");
2585        let dark = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2586        assert_eq!(theme.menu_bg, dark.menu_bg);
2587        assert_eq!(theme.diagnostic_error_fg, dark.diagnostic_error_fg);
2588    }
2589
2590    /// With neither `extends` nor an explicit `editor.bg`, there's nothing to
2591    /// infer from — the theme should still load and use the per-field
2592    /// hardcoded defaults rather than failing or picking an arbitrary builtin.
2593    #[test]
2594    fn test_no_inheritance_signal_uses_hardcoded_defaults() {
2595        let json = r#"{ "name": "x" }"#;
2596        let theme = Theme::from_json(json).expect("should parse");
2597        // The hardcoded `default_editor_bg` is `Rgb(30, 30, 30)`. Pin that so
2598        // a future change to the default prompts a deliberate test update.
2599        assert_eq!(theme.editor_bg, Color::Rgb(30, 30, 30));
2600    }
2601
2602    #[test]
2603    fn test_indentation_guide_fg_inherits_whitespace_indicator_fg_when_omitted() {
2604        let json = r#"{
2605            "name": "x",
2606            "extends": "builtin://dark",
2607            "editor": { "whitespace_indicator_fg": [12, 34, 56] }
2608        }"#;
2609        let theme = Theme::from_json(json).expect("should parse");
2610
2611        assert_eq!(theme.whitespace_indicator_fg, Color::Rgb(12, 34, 56));
2612        assert_eq!(theme.indentation_guide_fg, Color::Rgb(12, 34, 56));
2613    }
2614
2615    #[test]
2616    fn test_explicit_indentation_guide_fg_overrides_whitespace_fallback() {
2617        let json = r#"{
2618            "name": "x",
2619            "editor": {
2620                "whitespace_indicator_fg": [12, 34, 56],
2621                "indentation_guide_fg": [65, 67, 69]
2622            }
2623        }"#;
2624        let theme = Theme::from_json(json).expect("should parse");
2625
2626        assert_eq!(theme.whitespace_indicator_fg, Color::Rgb(12, 34, 56));
2627        assert_eq!(theme.indentation_guide_fg, Color::Rgb(65, 67, 69));
2628    }
2629
2630    /// `name` remains the only truly required top-level field. A theme JSON
2631    /// missing `name` should still be rejected with a clear error so users
2632    /// don't end up with an unidentifiable theme in the registry.
2633    #[test]
2634    fn test_theme_without_name_still_errors() {
2635        let json = r#"{ "editor": {} }"#;
2636        let err = Theme::from_json(json).expect_err("missing `name` must be an error");
2637        assert!(
2638            err.contains("name"),
2639            "error should mention the missing `name` field, got: {}",
2640            err
2641        );
2642    }
2643
2644    /// Overriding a single nested field on top of an explicit `extends` must
2645    /// only touch that field — every sibling stays at the base's value. This
2646    /// is the surgical-tweak workflow ("I love `dark` but want a different
2647    /// cursor color"), and the override walk must not bleed into other fields.
2648    #[test]
2649    fn test_extends_overrides_compose_field_by_field() {
2650        let json = r#"{
2651            "name": "dark-with-pink-cursor",
2652            "extends": "builtin://dark",
2653            "editor": { "cursor": [255, 105, 180] }
2654        }"#;
2655        let theme = Theme::from_json(json).expect("should parse");
2656        let dark = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2657
2658        // Cursor was overridden.
2659        assert_eq!(theme.cursor, Color::Rgb(255, 105, 180));
2660        // Every other editor field comes from the base verbatim.
2661        assert_eq!(theme.editor_bg, dark.editor_bg);
2662        assert_eq!(theme.editor_fg, dark.editor_fg);
2663        assert_eq!(theme.selection_bg, dark.selection_bg);
2664        // And so do the other sections.
2665        assert_eq!(theme.menu_bg, dark.menu_bg);
2666        assert_eq!(theme.syntax_keyword, dark.syntax_keyword);
2667    }
2668
2669    #[test]
2670    fn test_default_reset_color() {
2671        // Test that "Default" maps to Color::Reset
2672        let color: Color = ColorDef::Named("Default".to_string()).into();
2673        assert_eq!(color, Color::Reset);
2674
2675        // Test that "Reset" also maps to Color::Reset
2676        let color: Color = ColorDef::Named("Reset".to_string()).into();
2677        assert_eq!(color, Color::Reset);
2678    }
2679
2680    #[test]
2681    fn test_file_status_colors_fall_back_to_diagnostic_colors() {
2682        // A theme with NO file_status_* keys should inherit from diagnostic colors
2683        let json = r#"{
2684            "name": "test-fallback",
2685            "editor": {},
2686            "ui": {},
2687            "search": {},
2688            "diagnostic": {
2689                "error_fg": [220, 50, 47],
2690                "warning_fg": [181, 137, 0],
2691                "info_fg": [38, 139, 210],
2692                "hint_fg": [101, 123, 131]
2693            },
2694            "syntax": {}
2695        }"#;
2696        let theme = Theme::from_json(json).expect("Should parse theme without file_status keys");
2697
2698        // Verify fallback: added/renamed -> info_fg
2699        assert_eq!(theme.file_status_added_fg, Color::Rgb(38, 139, 210));
2700        assert_eq!(theme.file_status_renamed_fg, Color::Rgb(38, 139, 210));
2701        // modified -> warning_fg
2702        assert_eq!(theme.file_status_modified_fg, Color::Rgb(181, 137, 0));
2703        // deleted/conflicted -> error_fg
2704        assert_eq!(theme.file_status_deleted_fg, Color::Rgb(220, 50, 47));
2705        assert_eq!(theme.file_status_conflicted_fg, Color::Rgb(220, 50, 47));
2706        // untracked -> hint_fg
2707        assert_eq!(theme.file_status_untracked_fg, Color::Rgb(101, 123, 131));
2708    }
2709
2710    #[test]
2711    fn test_file_status_colors_explicit_override() {
2712        // A theme WITH explicit file_status keys should use those, not the fallback
2713        let json = r#"{
2714            "name": "test-override",
2715            "editor": {},
2716            "ui": {
2717                "file_status_added_fg": [80, 250, 123],
2718                "file_status_modified_fg": [255, 184, 108]
2719            },
2720            "search": {},
2721            "diagnostic": {
2722                "info_fg": [38, 139, 210],
2723                "warning_fg": [181, 137, 0]
2724            },
2725            "syntax": {}
2726        }"#;
2727        let theme = Theme::from_json(json).expect("Should parse theme with file_status overrides");
2728
2729        // Explicit overrides should win
2730        assert_eq!(theme.file_status_added_fg, Color::Rgb(80, 250, 123));
2731        assert_eq!(theme.file_status_modified_fg, Color::Rgb(255, 184, 108));
2732        // Non-overridden should still fall back
2733        assert_eq!(theme.file_status_renamed_fg, Color::Rgb(38, 139, 210));
2734    }
2735
2736    #[test]
2737    fn test_file_status_colors_resolve_via_theme_key() {
2738        let json = r#"{
2739            "name": "test-resolve",
2740            "editor": {},
2741            "ui": {
2742                "file_status_added_fg": [80, 250, 123]
2743            },
2744            "search": {},
2745            "diagnostic": {
2746                "warning_fg": [181, 137, 0]
2747            },
2748            "syntax": {}
2749        }"#;
2750        let theme = Theme::from_json(json).expect("Should parse theme");
2751
2752        // Theme key resolution should work for file_status keys
2753        assert_eq!(
2754            theme.resolve_theme_key("ui.file_status_added_fg"),
2755            Some(Color::Rgb(80, 250, 123))
2756        );
2757        assert_eq!(
2758            theme.resolve_theme_key("ui.file_status_modified_fg"),
2759            Some(Color::Rgb(181, 137, 0))
2760        );
2761    }
2762
2763    #[test]
2764    fn override_colors_writes_known_keys_and_drops_unknowns() {
2765        let mut theme = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2766        let applied = theme.override_colors([
2767            ("editor.bg".to_string(), Color::Rgb(10, 20, 30)),
2768            ("ui.status_bar_fg".to_string(), Color::Rgb(1, 2, 3)),
2769            ("does.not_exist".to_string(), Color::Rgb(9, 9, 9)),
2770            ("garbage_no_dot".to_string(), Color::Rgb(9, 9, 9)),
2771        ]);
2772        assert_eq!(applied, 2, "only the two valid keys should be applied");
2773        assert_eq!(
2774            theme.resolve_theme_key("editor.bg"),
2775            Some(Color::Rgb(10, 20, 30))
2776        );
2777        assert_eq!(
2778            theme.resolve_theme_key("ui.status_bar_fg"),
2779            Some(Color::Rgb(1, 2, 3))
2780        );
2781    }
2782
2783    #[test]
2784    fn resolve_theme_key_mut_matches_resolve_theme_key_domain() {
2785        // If a key resolves readably, it must also resolve as a mutable
2786        // slot — the two matches must stay in lock-step.
2787        let mut theme = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2788        let probe = [
2789            "editor.bg",
2790            "editor.fg",
2791            "ui.status_bar_fg",
2792            "ui.tab_active_bg",
2793            "syntax.keyword",
2794            "diagnostic.error_fg",
2795            "search.match_bg",
2796        ];
2797        for key in probe {
2798            assert!(
2799                theme.resolve_theme_key(key).is_some(),
2800                "reader lost key {key}"
2801            );
2802            assert!(
2803                theme.resolve_theme_key_mut(key).is_some(),
2804                "mutator missing key {key}"
2805            );
2806        }
2807    }
2808
2809    /// The set of `(section, field)` color keys that the theme's JSON
2810    /// surface — and therefore the plugin schema — exposes. Derived by
2811    /// taking a fully resolved builtin back to a `ThemeFile`, so every
2812    /// optional color slot is materialized as `Some`. Non-color leaves
2813    /// (text-attribute modifiers, `name`/`extends`) are filtered by shape.
2814    ///
2815    /// This is the single authority the resolvers/conversions are checked
2816    /// against: there is no hand-maintained key list to drift.
2817    fn schema_color_keys() -> Vec<(String, String)> {
2818        let theme = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2819        let file: ThemeFile = theme.into();
2820        let value = serde_json::to_value(&file).expect("ThemeFile serializes");
2821        let obj = value.as_object().expect("ThemeFile is a JSON object");
2822
2823        let mut keys = Vec::new();
2824        for section in ["editor", "ui", "search", "diagnostic", "syntax"] {
2825            let fields = obj
2826                .get(section)
2827                .and_then(|v| v.as_object())
2828                .unwrap_or_else(|| panic!("section `{section}` missing from serialized ThemeFile"));
2829            for (field, val) in fields {
2830                if is_color_leaf(val) {
2831                    keys.push((section.to_string(), field.clone()));
2832                }
2833            }
2834        }
2835        assert!(
2836            keys.len() >= 100,
2837            "expected the theme to expose at least ~100 color keys, found {} — \
2838             has the serialization shape changed?",
2839            keys.len()
2840        );
2841        keys
2842    }
2843
2844    /// A `ColorDef` JSON leaf is either a named-color string or an
2845    /// `[r, g, b]` array of three numbers. Text-attribute modifiers
2846    /// serialize as arrays of *strings*, so this excludes them.
2847    fn is_color_leaf(v: &serde_json::Value) -> bool {
2848        v.is_string()
2849            || v.as_array()
2850                .is_some_and(|a| a.len() == 3 && a.iter().all(serde_json::Value::is_number))
2851    }
2852
2853    /// Distinct, round-trip-stable sentinel color for index `i`. Always an
2854    /// RGB triple, which `ColorDef` passes through unchanged (only the named
2855    /// `Color` variants get folded into `ColorDef::Named`).
2856    fn sentinel(i: usize) -> Color {
2857        Color::Rgb((i >> 8) as u8, (i & 0xff) as u8, 0x5a)
2858    }
2859
2860    #[test]
2861    fn every_exposed_color_key_resolves_in_both_directions() {
2862        // Every color the JSON/schema surface exposes must be addressable by
2863        // BOTH resolvers, under the SAME section it appears in. A key the
2864        // schema advertises but a resolver drops is silently un-overridable
2865        // (the drift bug behind #2079): plugin overrides and theme-JSON loads
2866        // both go through `resolve_theme_key_mut`, the inspector through
2867        // `resolve_theme_key`.
2868        let mut theme = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2869        let mut missing_reader = Vec::new();
2870        let mut missing_mutator = Vec::new();
2871        for (section, field) in schema_color_keys() {
2872            let key = format!("{section}.{field}");
2873            if theme.resolve_theme_key(&key).is_none() {
2874                missing_reader.push(key.clone());
2875            }
2876            if theme.resolve_theme_key_mut(&key).is_none() {
2877                missing_mutator.push(key);
2878            }
2879        }
2880        assert!(
2881            missing_reader.is_empty() && missing_mutator.is_empty(),
2882            "theme color keys exposed by the JSON schema but dropped by a resolver:\n  \
2883             resolve_theme_key:     {missing_reader:?}\n  \
2884             resolve_theme_key_mut: {missing_mutator:?}"
2885        );
2886    }
2887
2888    #[test]
2889    fn color_keys_round_trip_through_the_same_field_and_section() {
2890        // Assign every exposed key a distinct color, then push it all the way
2891        // around the loop and back, checking the value lands in the same slot
2892        // at every hop:
2893        //   write    via resolve_theme_key_mut   (string key   -> Theme field)
2894        //   read     via resolve_theme_key        (Theme field  -> string key)
2895        //   serialize via From<Theme> for ThemeFile (Theme field -> section.field)
2896        //   reload   via from_json                 (section.field -> Theme field)
2897        // If field name OR section disagree between any of these four paths, a
2898        // sentinel lands in the wrong field and an assert fires — this is what
2899        // pins names and categories together in all directions.
2900        let keys = schema_color_keys();
2901        let mut theme = Theme::load_builtin(THEME_DARK).expect("dark builtin");
2902
2903        let pairs: Vec<(String, Color)> = keys
2904            .iter()
2905            .enumerate()
2906            .map(|(i, (s, f))| (format!("{s}.{f}"), sentinel(i)))
2907            .collect();
2908        let applied = theme.override_colors(pairs.iter().map(|(k, c)| (k.as_str(), *c)));
2909        assert_eq!(
2910            applied,
2911            keys.len(),
2912            "override_colors should write every exposed key via resolve_theme_key_mut"
2913        );
2914
2915        // reader agrees with mutator on which field each key addresses.
2916        for (i, (s, f)) in keys.iter().enumerate() {
2917            let key = format!("{s}.{f}");
2918            assert_eq!(
2919                theme.resolve_theme_key(&key),
2920                Some(sentinel(i)),
2921                "reader and mutator disagree on the field `{key}` addresses"
2922            );
2923        }
2924
2925        // reverse conversion serializes each sentinel back under the SAME
2926        // section.field — proves field name + category wiring in From<Theme>.
2927        let file: ThemeFile = theme.into();
2928        let value = serde_json::to_value(&file).expect("ThemeFile serializes");
2929        let obj = value.as_object().expect("ThemeFile is a JSON object");
2930        for (i, (s, f)) in keys.iter().enumerate() {
2931            let leaf = obj
2932                .get(s)
2933                .and_then(|sec| sec.get(f))
2934                .unwrap_or_else(|| panic!("`{s}.{f}` vanished from serialized ThemeFile"));
2935            let color: Color = serde_json::from_value::<ColorDef>(leaf.clone())
2936                .expect("color leaf parses as ColorDef")
2937                .into();
2938            assert_eq!(
2939                color,
2940                sentinel(i),
2941                "`{s}.{f}` serialized back to the wrong field or section"
2942            );
2943        }
2944
2945        // forward conversion (from_json) routes each section.field leaf back to
2946        // the field the reader reads — proves From<ThemeFile> for Theme wiring.
2947        let reloaded = Theme::from_json(&value.to_string()).expect("from_json round-trips");
2948        for (i, (s, f)) in keys.iter().enumerate() {
2949            let key = format!("{s}.{f}");
2950            assert_eq!(
2951                reloaded.resolve_theme_key(&key),
2952                Some(sentinel(i)),
2953                "`{key}` did not survive ThemeFile -> JSON -> from_json"
2954            );
2955        }
2956    }
2957
2958    #[test]
2959    fn test_all_builtin_themes_set_prominent_palette_indicator() {
2960        // Issue #1711: the Ctrl+P palette hint should be a *prominent*
2961        // accent drawn from each theme's own palette, not the neutral
2962        // status-bar colors. The fallback to status_bar_* exists for
2963        // user themes that don't opt in, but every shipped theme must
2964        // set explicit values that differ from the bar so the hint
2965        // pops as intended.
2966        for builtin in BUILTIN_THEMES {
2967            let theme = Theme::from_json(builtin.json)
2968                .unwrap_or_else(|e| panic!("Theme '{}' failed to parse: {}", builtin.name, e));
2969            assert!(
2970                theme.status_palette_fg != theme.status_bar_fg
2971                    || theme.status_palette_bg != theme.status_bar_bg,
2972                "Theme '{}' must set status_palette_fg/bg to a prominent \
2973                 accent distinct from status_bar_fg/bg",
2974                builtin.name
2975            );
2976        }
2977    }
2978
2979    #[test]
2980    fn test_all_builtin_themes_have_file_status_colors() {
2981        // Every builtin theme must produce valid file_status colors (via fallback or explicit)
2982        for builtin in BUILTIN_THEMES {
2983            let theme = Theme::from_json(builtin.json)
2984                .unwrap_or_else(|e| panic!("Theme '{}' failed to parse: {}", builtin.name, e));
2985
2986            // All six keys must resolve to Some via resolve_theme_key
2987            for key in &[
2988                "ui.file_status_added_fg",
2989                "ui.file_status_modified_fg",
2990                "ui.file_status_deleted_fg",
2991                "ui.file_status_renamed_fg",
2992                "ui.file_status_untracked_fg",
2993                "ui.file_status_conflicted_fg",
2994            ] {
2995                assert!(
2996                    theme.resolve_theme_key(key).is_some(),
2997                    "Theme '{}' missing resolution for '{}'",
2998                    builtin.name,
2999                    key
3000                );
3001            }
3002        }
3003    }
3004
3005    /// Regression for #2312: occurrence highlighting must use a
3006    /// theme-appropriate background in *every* shipped theme. A theme that
3007    /// omits `ui.semantic_highlight_bg` silently inherits the global default
3008    /// (a fixed dark color), which is invisible on dark high-contrast themes
3009    /// and an inverted block on light themes. Each builtin must therefore
3010    /// either define its own highlight color or opt into a modifier-only
3011    /// highlight (e.g. `reversed`/`bold`), and the resulting highlight must be
3012    /// visibly distinct from the editor background.
3013    #[test]
3014    fn test_all_builtin_themes_define_visible_occurrence_highlight() {
3015        for builtin in BUILTIN_THEMES {
3016            let raw: serde_json::Value = serde_json::from_str(builtin.json)
3017                .unwrap_or_else(|e| panic!("Theme '{}' is not valid JSON: {}", builtin.name, e));
3018            let ui = raw.get("ui").and_then(|u| u.as_object());
3019            let has_bg = ui.is_some_and(|u| u.contains_key("semantic_highlight_bg"));
3020            let has_modifier = ui.is_some_and(|u| u.contains_key("semantic_highlight_modifier"));
3021            assert!(
3022                has_bg || has_modifier,
3023                "Theme '{}' must explicitly define `ui.semantic_highlight_bg` (or a \
3024                 `semantic_highlight_modifier`) so occurrence highlighting is theme-appropriate \
3025                 instead of falling back to the hard-wired global default (#2312)",
3026                builtin.name
3027            );
3028
3029            let theme = Theme::from_json(builtin.json)
3030                .unwrap_or_else(|e| panic!("Theme '{}' failed to parse: {}", builtin.name, e));
3031            // When the highlight relies purely on a color (no SGR modifier such
3032            // as reversed/bold to make it stand out), that color must differ
3033            // from the editor background, otherwise the highlight is invisible.
3034            if theme
3035                .modifier_for_bg_key("ui.semantic_highlight_bg")
3036                .is_empty()
3037            {
3038                assert_ne!(
3039                    theme.semantic_highlight_bg, theme.editor_bg,
3040                    "Theme '{}': occurrence-highlight background equals the editor background \
3041                     (highlight would be invisible) and no modifier compensates (#2312)",
3042                    builtin.name
3043                );
3044            }
3045        }
3046    }
3047}