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(®);
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}