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