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