Skip to main content

lattice_ui_tui/
theme.rs

1//! UI theme (DESIGN.md §5.6, §5.12).
2//!
3//! Holds the customizable styling knobs the renderer reads each
4//! frame. v1 ships a built-in default that matches vim's classic
5//! split visuals (active status line reverse-videoed, inactive
6//! dim, vertical separator with `│`); every field is exposed via
7//! `:set ui.*` options so a user / config layer can override it.
8//!
9//! Adding a new themable surface is two edits: add a field to
10//! [`Theme`] with its default + add an `OptionSpec` in
11//! `crate::options::builtin_options()` that mutates that field.
12
13use ratatui::style::{Color, Modifier, Style};
14
15use lattice_host::ui::theme as host_theme;
16
17/// One full UI theme. Cheap to clone (Style + char fields are all
18/// `Copy`); the App holds it directly and `:set ui.*` writes
19/// through.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Theme {
22    /// Per-pane status line style for the active pane. Default
23    /// reverse-video + bold so focus is unambiguous regardless of
24    /// the user's terminal palette.
25    pub pane_status_active: Style,
26    /// Per-pane status line style for inactive panes. Default
27    /// dim + dark-gray foreground so the line is visible but
28    /// clearly secondary.
29    pub pane_status_inactive: Style,
30    /// Style applied to every span in an inactive document pane
31    /// when [`Self::dim_inactive_panes`] is true. Composes with
32    /// the existing syntax-highlight style. Default: `DIM`
33    /// modifier only -- preserves color, just reduces intensity.
34    pub inactive_pane_overlay: Style,
35    /// Whether inactive panes get [`Self::inactive_pane_overlay`]
36    /// applied on top of their syntax highlights. Off → inactive
37    /// panes look identical to active (just no terminal cursor).
38    pub dim_inactive_panes: bool,
39    /// Style for the vertical-split separator column (`│` between
40    /// side-by-side panes). Default: dark gray foreground, no bg.
41    pub pane_separator: Style,
42    /// Character drawn in the vertical-split separator column.
43    /// Default: `│` (U+2502, BOX DRAWINGS LIGHT VERTICAL).
44    pub pane_separator_vertical: char,
45    /// Character drawn in the horizontal-split separator row.
46    /// Default: `─` (U+2500). Currently unused -- horizontal
47    /// splits are visually delimited by the per-pane status line
48    /// at the bottom of the upper pane. Reserved for layouts
49    /// that disable per-pane status lines.
50    pub pane_separator_horizontal: char,
51
52    // ---- Modeline (ML.1b): per-role segments + active/inactive bar ----
53    // Pre-adapted from the resolved table (rebuilt at theme-change rate,
54    // never per frame). [`Theme::modeline_style`] composes a per-role fg
55    // over the active bar bg, or returns the muted inactive bar.
56    /// Active pane's modeline bar base (background only).
57    pub modeline_active: Style,
58    /// Inactive pane's modeline bar (uniform muted; applied to every
59    /// span on an inactive pane, no per-role colour).
60    pub modeline_inactive: Style,
61    /// Active-pane foreground for the modal-state label (`[NORMAL]`).
62    pub modeline_mode: Style,
63    /// Active-pane foreground for the buffer-path segment.
64    pub modeline_path: Style,
65    /// Active-pane foreground for the cursor line:column segment.
66    pub modeline_position: Style,
67    /// Active-pane foreground for the language-label segment.
68    pub modeline_lang: Style,
69    /// Active-pane foreground for mode-contributed items (LSP / diff).
70    pub modeline_mode_item: Style,
71
72    /// Popup header title (bold accent) — `ui.popup.title`. Shared with
73    /// the GPUI peer so the accent is themeable + identical across peers.
74    pub popup_title: Style,
75    /// Popup header hint (dim — e.g. "Esc to dismiss") — `ui.popup.hint`.
76    pub popup_hint: Style,
77
78    /// Style for directory entries in file-tree and oil buffers.
79    pub file_tree_dir_style: Style,
80    /// Style for hidden files (names starting with `.`).
81    pub file_tree_hidden_style: Style,
82    /// Base style for regular file entries.
83    pub file_tree_file_style: Style,
84    /// Whether to render file-type icons as Nerd Fonts v3 glyphs.
85    /// When false, the icons module emits the BMP-block fallback
86    /// palette (◆ ≡ ◇ ■ ♪ ▶ ·) that works in every modern monospace
87    /// font. Synced from the `ui.nerd_fonts` typed option in
88    /// `App::sync_theme_from_config`.
89    pub nerd_fonts: bool,
90
91    // ---- Diagnostics (Phase 4.1.d.iii) ---------------------
92    /// Glyph + color for an Error-severity diagnostic. Rendered
93    /// in the gutter's severity column; also drives the inline
94    /// underline color when an error range overlaps text.
95    pub diagnostic_error_glyph: char,
96    pub diagnostic_error_style: Style,
97    /// Warning severity.
98    pub diagnostic_warning_glyph: char,
99    pub diagnostic_warning_style: Style,
100    /// Information severity.
101    pub diagnostic_info_glyph: char,
102    pub diagnostic_info_style: Style,
103    /// Hint severity.
104    pub diagnostic_hint_glyph: char,
105    pub diagnostic_hint_style: Style,
106
107    // ---- M.7.3 whitespace decoration ---------------------
108    /// Style applied to "neutral" whitespace glyphs (tab,
109    /// leading, mid-text space, EOL). Default: dim dark-gray --
110    /// visible enough to read structure, quiet enough to not
111    /// fight the syntax highlight. Trailing whitespace gets a
112    /// louder style ([`Self::whitespace_trailing_style`]); they
113    /// split because trailing is a lint signal where the others
114    /// are structural.
115    pub whitespace_style: Style,
116    /// Style for trailing-whitespace glyphs. Default: red,
117    /// no modifier -- "this shouldn't be here" without
118    /// shouting.
119    pub whitespace_trailing_style: Style,
120
121    // ---- M.7.3 current-line highlight --------------------
122    /// Background applied to the cursor's row when
123    /// `current-line-highlight-mode` is active (M.7.2 minor /
124    /// `:set cursorline`). Default: a subtle dark gray
125    /// (`Color::Indexed(236)` -- the conventional darker-than-
126    /// background row tint in 256-color palettes). Active pane
127    /// only; selection bg wins per-cell when the two overlap.
128    pub cursor_line_bg: Color,
129
130    // ---- msg-mode.3: `*messages*` level highlights -------
131    /// Style for the timestamp prefix (`HH:MM:SS.mmm`) at the
132    /// start of every `*messages*` row. Dim by default so the
133    /// time doesn't compete with the level + body for
134    /// attention.
135    pub messages_timestamp_style: Style,
136    /// Style for the `TRACE` level token. Dim — `trace`-class
137    /// records are firehose-y; the user opts in via
138    /// `messages.filter` and shouldn't have them shout.
139    pub messages_trace_style: Style,
140    /// Style for the `DEBUG` level token. Cyan: distinct from
141    /// info but not alarming.
142    pub messages_debug_style: Style,
143    /// Style for the `INFO` level token. Default: terminal-
144    /// default fg, no modifier — neutral.
145    pub messages_info_style: Style,
146    /// Style for the `WARN` level token. Yellow + bold.
147    pub messages_warn_style: Style,
148    /// Style for the `ERROR` level token. Red + bold.
149    pub messages_error_style: Style,
150
151    // ---- Diff (D.3.b.3 2026-05-29) ---------------------------
152    /// Glyph color for the `+` sign in the diff gutter column
153    /// (D.3.d.1). The glyph itself is hardcoded `+`; this style
154    /// supplies its colour.
155    pub diff_add_sign_style: Style,
156    /// Glyph color for the `~` sign in the diff gutter column.
157    pub diff_change_sign_style: Style,
158    /// Glyph color for the `-` sign in the diff gutter column.
159    /// D.3.d.0 doesn't currently emit Remove on the current
160    /// side (deletion blocks surface removes), but the entry
161    /// is reserved for future renderers.
162    pub diff_remove_sign_style: Style,
163    /// D.6.f (2026-05-31): glyph colour for the `?` sign in
164    /// the diff gutter — three-way Conflict hunks.
165    pub diff_conflict_sign_style: Style,
166    /// Background tint applied to lines added in the current
167    /// side (D.3.e).
168    pub diff_add_line_bg: Color,
169    /// Background tint applied to lines changed in the current
170    /// side (D.3.e).
171    pub diff_change_line_bg: Color,
172    /// MC.3: full-width background tint for lines inside a fenced/indented
173    /// code block (`syntax.code_block`).
174    pub code_block_bg: Color,
175    /// D-fix.3b: background tint for lines removed on the baseline
176    /// (left) side of a side-by-side diff. Reuses the deletion-block
177    /// palette role (red).
178    pub diff_remove_line_bg: Color,
179    /// Background tint applied to deletion-block virtual rows
180    /// (D.3.b.1 / D.3.b.2). Sits behind the baseline-text cells
181    /// in the deletion block.
182    pub diff_deletion_block_bg: Color,
183    /// D.6.f (2026-05-31): background tint for three-way
184    /// conflict regions. Visually distinct from
185    /// Add/Change/Remove tints so users spot conflicts at a
186    /// glance even when the gutter glyph is off-screen.
187    pub diff_conflict_line_bg: Color,
188    /// CM.3d (2026-07-22): background tint for compilation
189    /// file-location lines (whole-line `compilation.location` bg).
190    pub compilation_location_bg: Color,
191    /// CM.3d (2026-07-22): link-like foreground for the file-path
192    /// portion of compilation location lines.
193    pub compilation_location_fg: Color,
194}
195
196impl Default for Theme {
197    fn default() -> Self {
198        Self {
199            pane_status_active: Style::new()
200                .add_modifier(Modifier::REVERSED)
201                .add_modifier(Modifier::BOLD),
202            pane_status_inactive: Style::new()
203                .fg(Color::Rgb(0x6c, 0x70, 0x86))
204                .add_modifier(Modifier::DIM),
205            inactive_pane_overlay: Style::new().add_modifier(Modifier::DIM),
206            dim_inactive_panes: true,
207            pane_separator: Style::new().fg(Color::Rgb(0x6c, 0x70, 0x86)),
208            pane_separator_vertical: '│',
209            pane_separator_horizontal: '─',
210            // ML.1b: defaults mirror the resolved `modeline.*` elements
211            // under the default (Catppuccin Mocha) palette — surface1 bar,
212            // blue/text/subtext/teal per-role fg, surface0+overlay muted.
213            modeline_active: Style::new().bg(Color::Rgb(0x45, 0x47, 0x5a)),
214            modeline_inactive: Style::new()
215                .bg(Color::Rgb(0x31, 0x32, 0x44))
216                .fg(Color::Rgb(0x6c, 0x70, 0x86)),
217            modeline_mode: Style::new()
218                .fg(Color::Rgb(0x89, 0xb4, 0xfa))
219                .add_modifier(Modifier::BOLD),
220            modeline_path: Style::new().fg(Color::Rgb(0xcd, 0xd6, 0xf4)),
221            modeline_position: Style::new().fg(Color::Rgb(0x93, 0x99, 0xb2)),
222            modeline_lang: Style::new().fg(Color::Rgb(0x94, 0xe2, 0xd5)),
223            modeline_mode_item: Style::new().fg(Color::Rgb(0x93, 0x99, 0xb2)),
224            // Popup header: blue bold title + overlay (muted) hint, the
225            // `ui.popup.title` / `ui.popup.hint` defaults under Catppuccin
226            // Mocha (matches the GPUI peer's `popup_title` / `popup_hint`).
227            popup_title: Style::new()
228                .fg(Color::Rgb(0x89, 0xb4, 0xfa))
229                .add_modifier(Modifier::BOLD),
230            popup_hint: Style::new().fg(Color::Rgb(0x6c, 0x70, 0x86)),
231            // Severity glyphs: solid square / triangle / circle /
232            // dot. Same shapes vim's nvim-lsp / VS Code use --
233            // immediately readable, terminal-safe.
234            diagnostic_error_glyph: '■',
235            diagnostic_error_style: Style::new()
236                .fg(Color::Rgb(0xf3, 0x8b, 0xa8))
237                .add_modifier(Modifier::BOLD),
238            diagnostic_warning_glyph: '▲',
239            diagnostic_warning_style: Style::new()
240                .fg(Color::Rgb(0xf9, 0xe2, 0xaf))
241                .add_modifier(Modifier::BOLD),
242            diagnostic_info_glyph: '●',
243            diagnostic_info_style: Style::new().fg(Color::Rgb(0x89, 0xb4, 0xfa)),
244            diagnostic_hint_glyph: '·',
245            diagnostic_hint_style: Style::new()
246                .fg(Color::Rgb(0x6c, 0x70, 0x86))
247                .add_modifier(Modifier::DIM),
248            file_tree_dir_style: Style::new()
249                .fg(Color::Rgb(0x89, 0xb4, 0xfa))
250                .add_modifier(Modifier::BOLD),
251            file_tree_hidden_style: Style::new()
252                .fg(Color::Rgb(0x6c, 0x70, 0x86))
253                .add_modifier(Modifier::DIM),
254            file_tree_file_style: Style::new(),
255            // Default to the BMP fallback so the first frame works
256            // in any terminal font. Users on a Nerd-Font-patched
257            // terminal opt in via `:set ui.nerd_fonts on`.
258            nerd_fonts: false,
259            // M.7.3: whitespace + current-line defaults.
260            whitespace_style: Style::new()
261                .fg(Color::Rgb(0x6c, 0x70, 0x86))
262                .add_modifier(Modifier::DIM),
263            whitespace_trailing_style: Style::new().fg(Color::Rgb(0xf3, 0x8b, 0xa8)),
264            cursor_line_bg: Color::Indexed(236),
265            // msg-mode.3: matches the format produced by
266            // `crate::app::messages::format_message_record`.
267            messages_timestamp_style: Style::new()
268                .fg(Color::Rgb(0x6c, 0x70, 0x86))
269                .add_modifier(Modifier::DIM),
270            messages_trace_style: Style::new().add_modifier(Modifier::DIM),
271            messages_debug_style: Style::new().fg(Color::Rgb(0x74, 0xc7, 0xec)),
272            messages_info_style: Style::new().fg(Color::Rgb(0xa6, 0xe3, 0xa1)),
273            messages_warn_style: Style::new()
274                .fg(Color::Rgb(0xf9, 0xe2, 0xaf))
275                .add_modifier(Modifier::BOLD),
276            messages_error_style: Style::new()
277                .fg(Color::Rgb(0xf3, 0x8b, 0xa8))
278                .add_modifier(Modifier::BOLD),
279
280            // D.3.b.3 (2026-05-29): defaults mirror
281            // `host_theme::Theme::default()` Diff section.
282            diff_add_sign_style: Style::new()
283                .fg(Color::Rgb(0xa6, 0xe3, 0xa1))
284                .add_modifier(Modifier::BOLD),
285            diff_change_sign_style: Style::new()
286                .fg(Color::Rgb(0xf9, 0xe2, 0xaf))
287                .add_modifier(Modifier::BOLD),
288            diff_remove_sign_style: Style::new()
289                .fg(Color::Rgb(0xf3, 0x8b, 0xa8))
290                .add_modifier(Modifier::BOLD),
291            diff_conflict_sign_style: Style::new()
292                .fg(Color::Rgb(0xcb, 0xa6, 0xf7))
293                .add_modifier(Modifier::BOLD),
294            diff_add_line_bg: Color::Rgb(0, 50, 0),
295            diff_change_line_bg: Color::Rgb(50, 50, 0),
296            code_block_bg: Color::Rgb(0x31, 0x32, 0x44),
297            diff_remove_line_bg: Color::Rgb(60, 0, 0),
298            diff_deletion_block_bg: Color::Rgb(60, 0, 0),
299            diff_conflict_line_bg: Color::Rgb(60, 0, 60),
300            compilation_location_bg: Color::Rgb(0x45, 0x47, 0x5a),
301            compilation_location_fg: Color::Rgb(0x89, 0xb4, 0xfa),
302        }
303    }
304}
305
306impl Theme {
307    /// Resolve a modeline span's style from its `ModelineRole` and the
308    /// pane's active state (ML.1b). Inactive panes render uniformly muted
309    /// (the `modeline.inactive` bar); active panes compose the per-role
310    /// foreground over the `modeline.active` bar background. Unknown roles
311    /// and padding (`None`) get the bar base only — so a plugin's
312    /// not-yet-themed role still sits on the bar rather than vanishing.
313    pub fn modeline_style(&self, role: Option<&str>, is_active: bool) -> Style {
314        use lattice_host::modeline as ml;
315        if !is_active {
316            return self.modeline_inactive;
317        }
318        let fg = match role {
319            Some(ml::ROLE_MODE) => self.modeline_mode,
320            Some(ml::ROLE_PATH) => self.modeline_path,
321            Some(ml::ROLE_POSITION) => self.modeline_position,
322            Some(ml::ROLE_LANG) => self.modeline_lang,
323            Some(ml::ROLE_MODE_ITEM) => self.modeline_mode_item,
324            _ => Style::default(),
325        };
326        self.modeline_active.patch(fg)
327    }
328}
329
330/// Resolve the per-severity rendering bits from the theme.
331/// Returns `(glyph, style)`.
332pub fn diagnostic_glyph_and_style(
333    theme: &Theme,
334    severity: lattice_lsp::DiagnosticSeverity,
335) -> (char, Style) {
336    match severity {
337        lattice_lsp::DiagnosticSeverity::ERROR => {
338            (theme.diagnostic_error_glyph, theme.diagnostic_error_style)
339        }
340        lattice_lsp::DiagnosticSeverity::WARNING => (
341            theme.diagnostic_warning_glyph,
342            theme.diagnostic_warning_style,
343        ),
344        lattice_lsp::DiagnosticSeverity::INFORMATION => {
345            (theme.diagnostic_info_glyph, theme.diagnostic_info_style)
346        }
347        lattice_lsp::DiagnosticSeverity::HINT => {
348            (theme.diagnostic_hint_glyph, theme.diagnostic_hint_style)
349        }
350        _ => (theme.diagnostic_info_glyph, theme.diagnostic_info_style),
351    }
352}
353
354/// Parse a user-typed color name into a ratatui [`Color`].
355///
356/// Phase 5.3: delegates to `lattice_host::ui::theme::parse_color`
357/// (the canonical parser) and converts the host [`host_theme::Color`]
358/// into a ratatui [`Color`]. The validation surface (accepted
359/// names, error format) stays identical so `:set ui.*_color=...`
360/// behaves the same. Hex colors arrive post-1.0 (depends on a
361/// terminal-true-color check).
362pub fn parse_color(s: &str) -> Result<Color, String> {
363    host_theme::parse_color(s).map(host_color_to_ratatui)
364}
365
366/// Adapt a renderer-neutral [`host_theme::Color`] into a ratatui
367/// [`Color`]. Lossless on all variants except `Rgb` when the
368/// terminal doesn't support truecolor -- in that case the
369/// renderer's frame submission stage handles the lossy fallback
370/// (ratatui already does palette closest-match itself).
371pub fn host_color_to_ratatui(c: host_theme::Color) -> Color {
372    use host_theme::NamedColor as N;
373    match c {
374        host_theme::Color::Default => Color::Reset,
375        host_theme::Color::Named(N::Black) => Color::Black,
376        host_theme::Color::Named(N::Red) => Color::Red,
377        host_theme::Color::Named(N::Green) => Color::Green,
378        host_theme::Color::Named(N::Yellow) => Color::Yellow,
379        host_theme::Color::Named(N::Blue) => Color::Blue,
380        host_theme::Color::Named(N::Magenta) => Color::Magenta,
381        host_theme::Color::Named(N::Cyan) => Color::Cyan,
382        host_theme::Color::Named(N::Gray) => Color::Gray,
383        host_theme::Color::Named(N::DarkGray) => Color::DarkGray,
384        host_theme::Color::Named(N::LightRed) => Color::LightRed,
385        host_theme::Color::Named(N::LightGreen) => Color::LightGreen,
386        host_theme::Color::Named(N::LightYellow) => Color::LightYellow,
387        host_theme::Color::Named(N::LightBlue) => Color::LightBlue,
388        host_theme::Color::Named(N::LightMagenta) => Color::LightMagenta,
389        host_theme::Color::Named(N::LightCyan) => Color::LightCyan,
390        host_theme::Color::Named(N::White) => Color::White,
391        host_theme::Color::Indexed(idx) => Color::Indexed(idx),
392        host_theme::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
393    }
394}
395
396/// Adapt a renderer-neutral [`host_theme::Style`] into a ratatui
397/// [`Style`]. Empty fg/bg map to "unset" on the ratatui side
398/// (same as `Style::default()`); modifiers chain via
399/// `add_modifier`.
400pub fn host_style_to_ratatui(s: host_theme::Style) -> Style {
401    let mut style = Style::default();
402    if let Some(fg) = s.fg {
403        style = style.fg(host_color_to_ratatui(fg));
404    }
405    if let Some(bg) = s.bg {
406        style = style.bg(host_color_to_ratatui(bg));
407    }
408    // T.10: degrade the rich-vocabulary `weight` on the fixed grid. A
409    // SemiBold-or-heavier weight maps to ratatui's bold attribute, so an
410    // element that sets `weight` but NOT the bold bool still renders bold
411    // on the TUI. (Builtin headings set both, so this is a no-op for
412    // them; it makes weight degrade correctly in general.) `scale` /
413    // `family` are no-ops on the TUI (single grid font) — correct, not a
414    // defect.
415    let heavy_weight = matches!(
416        s.weight,
417        Some(
418            host_theme::Weight::SemiBold
419                | host_theme::Weight::Bold
420                | host_theme::Weight::ExtraBold
421                | host_theme::Weight::Black
422        )
423    );
424    if s.modifiers.bold || heavy_weight {
425        style = style.add_modifier(Modifier::BOLD);
426    }
427    if s.modifiers.italic {
428        style = style.add_modifier(Modifier::ITALIC);
429    }
430    if s.modifiers.underline {
431        style = style.add_modifier(Modifier::UNDERLINED);
432    }
433    if s.modifiers.dim {
434        style = style.add_modifier(Modifier::DIM);
435    }
436    if s.modifiers.reverse {
437        style = style.add_modifier(Modifier::REVERSED);
438    }
439    style
440}
441
442/// Build the ratatui-typed [`Theme`] cache from the resolved theme
443/// table **plus** the non-style `ui.*` typed options. The TUI's
444/// `App.theme` cache is the renderer's hot-path adapted view; it is
445/// rebuilt at theme-change / option-set rate, never per frame.
446///
447/// T.4 (theme-system): the *style* elements source from the
448/// [`host_theme::ResolvedTheme`] table (the single source of truth) via
449/// the captured [`host_theme::BuiltinElementIds`]. The cache itself
450/// stays — it is the safeguard that keeps per-line decoration painting
451/// free of per-frame style adaptation (design §10.1;
452/// `feedback_renderer_cache_protects_ux`).
453///
454/// T.6.t: the host `Theme` struct is deleted; the 8 non-style fields
455/// (`dim_inactive_panes`, `pane_separator_{vertical,horizontal}`,
456/// `nerd_fonts`, the four `diagnostic_*_glyph` chars) now source from
457/// the typed-options registry via `config` (default-fallback matches
458/// the deleted struct's literals exactly). Style fields keep sourcing
459/// from `resolved` / `ids`.
460pub fn build_tui_theme(
461    config: &lattice_config::ConfigRegistry,
462    resolved: &host_theme::ResolvedTheme,
463    ids: &host_theme::BuiltinElementIds,
464) -> Theme {
465    use lattice_host::ui::theme_options::{
466        UiDiagnosticErrorGlyph, UiDiagnosticHintGlyph, UiDiagnosticInfoGlyph,
467        UiDiagnosticWarningGlyph, UiDimInactive, UiNerdFonts, UiSeparator, UiSeparatorHorizontal,
468    };
469    // Read a single-char `ui.*` glyph option, falling back to `dflt`
470    // (matches the deleted host `Theme::default()` literals).
471    let glyph = |opt: Option<std::sync::Arc<String>>, dflt: char| -> char {
472        opt.and_then(|s| s.chars().next()).unwrap_or(dflt)
473    };
474    let dim_inactive_panes = config
475        .get_typed::<UiDimInactive>()
476        .map(|v| *v)
477        .unwrap_or(true);
478    let nerd_fonts = config
479        .get_typed::<UiNerdFonts>()
480        .map(|v| *v)
481        .unwrap_or(false);
482    let pane_separator_vertical = glyph(config.get_typed::<UiSeparator>(), '│');
483    let pane_separator_horizontal = glyph(config.get_typed::<UiSeparatorHorizontal>(), '─');
484    let diagnostic_error_glyph = glyph(config.get_typed::<UiDiagnosticErrorGlyph>(), '■');
485    let diagnostic_warning_glyph = glyph(config.get_typed::<UiDiagnosticWarningGlyph>(), '▲');
486    let diagnostic_info_glyph = glyph(config.get_typed::<UiDiagnosticInfoGlyph>(), '●');
487    let diagnostic_hint_glyph = glyph(config.get_typed::<UiDiagnosticHintGlyph>(), '·');
488    let resolved_style = |id| host_style_to_ratatui(resolved.get(id));
489    // Background tint from an element's resolved `bg` channel (the diff
490    // tint elements set `bg`); `Reset` = no tint if unresolved.
491    let resolved_bg = |id| {
492        resolved
493            .get(id)
494            .bg
495            .map(host_color_to_ratatui)
496            .unwrap_or(Color::Reset)
497    };
498    Theme {
499        // T.9: pane chrome styles resolve through the elements;
500        // `:set ui.statusline_*_fg`/`separator_color` now write
501        // registry overrides (not host `Theme`).
502        pane_status_active: resolved_style(ids.pane_status_active),
503        pane_status_inactive: resolved_style(ids.pane_status_inactive),
504        // ML.1b: modeline per-role + bar styles from the resolved
505        // `modeline.*` elements (palette-driven across all themes).
506        modeline_active: resolved_style(ids.modeline_active),
507        modeline_inactive: resolved_style(ids.modeline_inactive),
508        modeline_mode: resolved_style(ids.modeline_mode),
509        modeline_path: resolved_style(ids.modeline_path),
510        modeline_position: resolved_style(ids.modeline_position),
511        modeline_lang: resolved_style(ids.modeline_lang),
512        modeline_mode_item: resolved_style(ids.modeline_mode_item),
513        // Popup header title / hint from the resolved `ui.popup.*`
514        // elements (palette-driven, shared with the GPUI peer).
515        popup_title: resolved_style(ids.ui_popup_title),
516        popup_hint: resolved_style(ids.ui_popup_hint),
517        // T.4.c: inactive-pane overlay + file-tree styles source
518        // from the resolved table. Separator chars + `dim`/
519        // `nerd_fonts` flags stay on `h` (non-style → T.6.t).
520        inactive_pane_overlay: resolved_style(ids.pane_inactive_overlay),
521        // T.6.t: non-style chrome from typed options (see locals above).
522        dim_inactive_panes,
523        pane_separator: resolved_style(ids.pane_separator),
524        pane_separator_vertical,
525        pane_separator_horizontal,
526        file_tree_dir_style: resolved_style(ids.file_tree_dir),
527        file_tree_hidden_style: resolved_style(ids.file_tree_hidden),
528        file_tree_file_style: resolved_style(ids.file_tree_file),
529        nerd_fonts,
530        // T.4.a: diagnostic styles source from the resolved table
531        // (`diagnostic.{error,warning,info,hint}`); T.6.t: glyphs
532        // from the `ui.diagnostic-*-glyph` typed options.
533        diagnostic_error_glyph,
534        diagnostic_error_style: resolved_style(ids.diagnostic_error),
535        diagnostic_warning_glyph,
536        diagnostic_warning_style: resolved_style(ids.diagnostic_warning),
537        diagnostic_info_glyph,
538        diagnostic_info_style: resolved_style(ids.diagnostic_info),
539        diagnostic_hint_glyph,
540        diagnostic_hint_style: resolved_style(ids.diagnostic_hint),
541        // T.4.d: whitespace stays on `h` (cell-path → T.5);
542        // current-line tint + *messages* levels source from the
543        // resolved table.
544        // T.5.c: whitespace markers source from the resolved table.
545        whitespace_style: resolved_style(ids.whitespace),
546        whitespace_trailing_style: resolved_style(ids.whitespace_trailing),
547        cursor_line_bg: resolved_bg(ids.editor_cursor_line),
548        messages_timestamp_style: resolved_style(ids.messages_timestamp),
549        messages_trace_style: resolved_style(ids.messages_trace),
550        messages_debug_style: resolved_style(ids.messages_debug),
551        messages_info_style: resolved_style(ids.messages_info),
552        messages_warn_style: resolved_style(ids.messages_warn),
553        messages_error_style: resolved_style(ids.messages_error),
554        // T.4.b: diff signs (fg) + line/block tints (bg) source
555        // from the resolved table (`diff.*`).
556        diff_add_sign_style: resolved_style(ids.diff_add_sign),
557        diff_change_sign_style: resolved_style(ids.diff_change_sign),
558        diff_remove_sign_style: resolved_style(ids.diff_remove_sign),
559        diff_conflict_sign_style: resolved_style(ids.diff_conflict_sign),
560        diff_add_line_bg: resolved_bg(ids.diff_add_line),
561        diff_change_line_bg: resolved_bg(ids.diff_change_line),
562        code_block_bg: resolved_bg(ids.syntax_code_block),
563        diff_remove_line_bg: resolved_bg(ids.diff_remove_line),
564        diff_deletion_block_bg: resolved_bg(ids.diff_deletion_block),
565        diff_conflict_line_bg: resolved_bg(ids.diff_conflict_line),
566        compilation_location_bg: Color::Rgb(0x45, 0x47, 0x5a),
567        compilation_location_fg: Color::Rgb(0x89, 0xb4, 0xfa),
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    #![allow(clippy::unwrap_used)]
574    use super::*;
575
576    #[test]
577    fn default_theme_dims_inactive_panes() {
578        let t = Theme::default();
579        assert!(t.dim_inactive_panes);
580    }
581
582    #[test]
583    fn default_separator_is_box_drawing_vertical() {
584        assert_eq!(Theme::default().pane_separator_vertical, '│');
585    }
586
587    #[test]
588    fn parse_color_named() {
589        assert_eq!(parse_color("red").unwrap(), Color::Red);
590        assert_eq!(parse_color("DarkGray").unwrap(), Color::DarkGray);
591        assert_eq!(parse_color("default").unwrap(), Color::Reset);
592    }
593
594    #[test]
595    fn parse_color_unknown_errors() {
596        assert!(parse_color("rainbow").is_err());
597    }
598
599    #[test]
600    fn default_options_adapt_to_tui_theme_default() {
601        // T.6.t: the host `Theme` struct is gone. The cache builder now
602        // sources style fields from the resolved default table and the 8
603        // non-style fields from the `ui.*` typed options at their
604        // defaults. The result must equal the ratatui `Theme::default()`
605        // the TUI hand-rolls — proving the migration is byte-identical
606        // (default glyphs `■▲●·`, separators `│`/`─`, dim on, nerd-fonts
607        // off).
608        use host_theme::ThemeRegistry as _;
609        let reg = host_theme::InMemoryThemeRegistry::with_defaults();
610        let resolved = reg.resolved();
611        let ids = host_theme::BuiltinElementIds::capture(&reg);
612        let config = lattice_config::ConfigRegistry::new();
613        config.init_from_linkme();
614        let built = build_tui_theme(&config, &resolved, &ids);
615        let tui: Theme = Theme::default();
616        assert_eq!(
617            built, tui,
618            "default ui.* options + resolved table must build the TUI theme default"
619        );
620    }
621
622    #[test]
623    fn host_weight_degrades_to_bold_without_bold_bool() {
624        // T.10: a host style that sets a heavy `weight` but NOT the bold
625        // bool still renders bold on the fixed-grid TUI (degrade path).
626        let s = host_theme::Style::empty().weight(host_theme::Weight::Bold);
627        assert!(!s.modifiers.bold, "no bold bool on the source style");
628        let r = host_style_to_ratatui(s);
629        assert!(
630            r.add_modifier.contains(Modifier::BOLD),
631            "heavy weight degrades to ratatui bold"
632        );
633    }
634
635    #[test]
636    fn host_light_weight_does_not_force_bold() {
637        // A weight lighter than SemiBold must NOT spuriously bold the TUI.
638        let s = host_theme::Style::empty().weight(host_theme::Weight::Light);
639        let r = host_style_to_ratatui(s);
640        assert!(
641            !r.add_modifier.contains(Modifier::BOLD),
642            "sub-SemiBold weight stays non-bold on the TUI"
643        );
644    }
645
646    #[test]
647    fn parse_color_routes_through_host() {
648        // The TUI parser delegates to the host parser. Pin the
649        // observable behaviour: same string → equivalent
650        // ratatui Color.
651        assert_eq!(parse_color("red").unwrap(), Color::Red);
652        assert_eq!(parse_color("default").unwrap(), Color::Reset);
653        assert_eq!(parse_color("DarkGray").unwrap(), Color::DarkGray);
654        assert!(parse_color("rainbow").is_err());
655    }
656}