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