Skip to main content

lattice_syntax/
theme_style.rs

1//! Syntax → theme-element style bridge.
2//!
3//! Maps a [`crate::style::Style`] syntax category to its builtin
4//! `syntax.*` / `diagnostic.*` [`lattice_theme::ElementId`], and resolves
5//! that to a concrete visual [`lattice_theme::Style`] via the active
6//! theme's resolved table. The single source of the syntax→element
7//! mapping, shared by the cell builder, both renderers' display-line
8//! paths, and the diff overlay.
9//!
10//! **Home (DX.2, BC.6 diff extraction).** This lived in `lattice-host`
11//! (`ui::theme`) while the only consumers were host-side. It moves here so
12//! `lattice-diff` (the diff overlay) can reach it without the host. It
13//! belongs in `lattice-syntax` rather than the `lattice-theme` leaf
14//! because the bridge is inherently *syntax-aware* — it takes a
15//! `lattice_syntax::Style` — so the dependency points DOWN from this
16//! higher crate onto the theme leaf, keeping `lattice-theme` a minimal
17//! renderer-hot-path leaf (it deps only arc-swap + tracing). Host keeps a
18//! façade re-export (`lattice_host::ui::theme::resolve_syntax_style`) so
19//! existing call sites are unchanged.
20
21use crate::style::Style;
22use lattice_theme::{BuiltinElementIds, ElementId, ResolvedTheme};
23
24/// Map a [`Style`] syntax category to its builtin `syntax.*`
25/// [`ElementId`]. The single source of the syntax→element mapping,
26/// shared by the cell builder, both renderers' display-line paths, and
27/// the diff overlay.
28pub fn syntax_element_id(ids: &BuiltinElementIds, style: Style) -> ElementId {
29    use crate::style::Style as S;
30    match style {
31        S::Default => ids.syntax_default,
32        S::Comment => ids.syntax_comment,
33        S::LineComment => ids.syntax_line_comment,
34        S::String => ids.syntax_string,
35        S::Keyword => ids.syntax_keyword,
36        S::Type => ids.syntax_type,
37        S::Number => ids.syntax_number,
38        S::Function => ids.syntax_function,
39        S::Constant => ids.syntax_constant,
40        S::Variable => ids.syntax_variable,
41        S::Operator => ids.syntax_operator,
42        S::Punctuation => ids.syntax_punctuation,
43        S::Attribute => ids.syntax_attribute,
44        S::Heading1 => ids.syntax_heading_1,
45        S::Heading2 => ids.syntax_heading_2,
46        S::Heading3 => ids.syntax_heading_3,
47        S::Heading4 => ids.syntax_heading_4,
48        S::Heading5 => ids.syntax_heading_5,
49        S::Heading6 => ids.syntax_heading_6,
50        S::Bold => ids.syntax_bold,
51        S::Italic => ids.syntax_italic,
52        S::Link => ids.syntax_link,
53        S::Url => ids.syntax_url,
54        S::MarkupRaw => ids.syntax_markup_raw,
55        S::Markup => ids.syntax_markup,
56        // L4b: diagnostic-severity styles (the `gl` popup) reuse the
57        // gutter/underline severity element colours.
58        S::DiagnosticError => ids.diagnostic_error,
59        S::DiagnosticWarning => ids.diagnostic_warning,
60        S::DiagnosticInfo => ids.diagnostic_info,
61        S::DiagnosticHint => ids.diagnostic_hint,
62        S::DiffAdd => ids.diff_add_text,
63        S::DiffRemove => ids.diff_remove_text,
64        // `*messages*`: the mode publishes these, and BOTH renderers resolve
65        // them here — which is the whole point. The TUI used to colour the log
66        // itself behind `if is_messages_buffer` and GPUI had no equivalent, so
67        // the same buffer was highlighted in one peer and plain in the other.
68        S::MessagesTimestamp => ids.messages_timestamp,
69        S::MessagesTrace => ids.messages_trace,
70        S::MessagesDebug => ids.messages_debug,
71        S::MessagesInfo => ids.messages_info,
72        S::MessagesWarn => ids.messages_warn,
73        S::MessagesError => ids.messages_error,
74        S::MagitSha => ids.magit_sha,
75        S::MagitBranchCurrent => ids.magit_branch_current,
76        S::MagitRefDecoration => ids.magit_ref_decoration,
77        S::MagitRebaseVerb => ids.magit_rebase_verb,
78        S::MagitAuthor => ids.magit_author,
79        // HP.2: help's inline literals. Both renderers resolve through
80        // this one function, so classifying here is all either needs —
81        // there is no per-renderer match arm to keep in step.
82        S::HelpKey => ids.help_key,
83        S::HelpCommand => ids.help_command,
84        S::HelpAction => ids.help_action,
85        S::HelpLiteral => ids.help_literal,
86        // DL.3a: inline virtual text. The element already existed;
87        // the worker was painting a hardcoded DarkGray past it.
88        S::InlayHint => ids.inlay_hint,
89        // DL.1: already an element id — a mode- or plugin-registered
90        // one that has no builtin slot, which is the whole point.
91        // Resolution downstream is identical to every arm above.
92        S::Element(id) => id,
93    }
94}
95
96/// Resolve a syntax category to its concrete [`lattice_theme::Style`] via
97/// the resolved table. The replacement for the deleted
98/// `Theme::syntax_style` color `match` — colors now flow from the active
99/// theme's palette through the resolved table. Every syntax consumer
100/// (cell builder, display-line paths, diff overlay) calls this, then
101/// adapts the host `Style` to its renderer-native form.
102pub fn resolve_syntax_style(
103    resolved: &ResolvedTheme,
104    ids: &BuiltinElementIds,
105    style: Style,
106) -> lattice_theme::Style {
107    resolved.get(syntax_element_id(ids, style))
108}
109
110#[cfg(test)]
111mod tests {
112    #![allow(clippy::unwrap_used)]
113    use super::*;
114    use lattice_theme::{
115        BuiltinElementIds, Color, InMemoryThemeRegistry, ResolvedTheme, ThemeRegistry,
116    };
117
118    // ---- T.5.b: SyntaxStyle resolution via the resolved table ----
119    //
120    // The legacy `Theme::syntax_style` color `match` was deleted in
121    // T.5.b; every consumer now resolves through `resolve_syntax_style`
122    // against the active theme's resolved table. These pin that the
123    // default resolved table reproduces the legacy Catppuccin-Mocha
124    // literals exactly (keyword = mauve + bold, comment = overlay0 +
125    // italic, default = text), so the cutover is colour-identical.
126
127    /// Build the resolved table + builtin ids from the default registry —
128    /// the same construction every renderer uses at boot.
129    fn defaults() -> (std::sync::Arc<ResolvedTheme>, BuiltinElementIds) {
130        let reg = InMemoryThemeRegistry::with_defaults();
131        let resolved = reg.resolved();
132        let ids = BuiltinElementIds::capture(&reg);
133        (resolved, ids)
134    }
135
136    /// DL.1: a span may name a **mode-registered** element, and it
137    /// resolves through exactly the same path as a builtin category.
138    ///
139    /// Before this, `StyledSpan` could only carry a closed `Style`
140    /// variant bridged to `BuiltinElementIds` — so an element
141    /// registered by a mode (or, post-1.0, by a WASM plugin, which
142    /// cannot add a Rust enum variant at all) had no way to style a
143    /// span. Themed highlighting outside the core vocabulary was
144    /// impossible by construction.
145    #[test]
146    fn a_mode_registered_element_can_style_a_span() {
147        use lattice_theme::{ElementOwner, StyleSpec};
148        let reg = InMemoryThemeRegistry::with_defaults();
149        let id = reg.register(
150            "listing.file.rust".into(),
151            ElementOwner::Mode("directory-listing-mode".into()),
152            StyleSpec::new().fg(Color::Rgb(0xDE, 0xA5, 0x84)),
153            "test element",
154        );
155        let ids = BuiltinElementIds::capture(&reg);
156
157        // The bridge hands the id straight back …
158        assert_eq!(syntax_element_id(&ids, Style::Element(id)), id);
159        // … and resolution yields the element's own style.
160        let resolved = reg.resolved();
161        let s = resolve_syntax_style(&resolved, &ids, Style::Element(id));
162        assert_eq!(s.fg, Some(Color::Rgb(0xDE, 0xA5, 0x84)));
163    }
164
165    /// The payload participates in the cache fingerprint: two spans
166    /// naming different elements must not collide, or retuning a theme
167    /// element would leave a stale matrix painted.
168    #[test]
169    fn element_styles_fingerprint_distinctly() {
170        let reg = InMemoryThemeRegistry::with_defaults();
171        let a = reg.register(
172            "listing.file.rust".into(),
173            lattice_theme::ElementOwner::Mode("m".into()),
174            lattice_theme::StyleSpec::new(),
175            "a",
176        );
177        let b = reg.register(
178            "listing.file.python".into(),
179            lattice_theme::ElementOwner::Mode("m".into()),
180            lattice_theme::StyleSpec::new(),
181            "b",
182        );
183        assert_ne!(a, b, "precondition: distinct ids");
184        assert_ne!(
185            Style::Element(a).fingerprint(),
186            Style::Element(b).fingerprint()
187        );
188        assert_ne!(
189            Style::Element(a).fingerprint(),
190            Style::Keyword.fingerprint()
191        );
192        assert_eq!(
193            Style::Keyword.fingerprint(),
194            Style::Keyword.fingerprint(),
195            "stable within a process"
196        );
197    }
198
199    #[test]
200    fn resolve_syntax_keyword_carries_catppuccin_mauve_bold() {
201        let (resolved, ids) = defaults();
202        let s = resolve_syntax_style(&resolved, &ids, Style::Keyword);
203        assert_eq!(s.fg, Some(Color::Rgb(0xcb, 0xa6, 0xf7)));
204        assert!(s.modifiers.bold);
205    }
206
207    #[test]
208    fn resolve_syntax_comment_is_overlay0_italic() {
209        let (resolved, ids) = defaults();
210        let s = resolve_syntax_style(&resolved, &ids, Style::Comment);
211        assert_eq!(s.fg, Some(Color::Rgb(0x6c, 0x70, 0x86)));
212        assert!(s.modifiers.italic);
213        // Same shape for LineComment.
214        let line = resolve_syntax_style(&resolved, &ids, Style::LineComment);
215        assert_eq!(line, s);
216    }
217
218    #[test]
219    fn resolve_syntax_default_uses_text_foreground() {
220        let (resolved, ids) = defaults();
221        let s = resolve_syntax_style(&resolved, &ids, Style::Default);
222        assert_eq!(s.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
223    }
224
225    /// L4b: the diagnostic-severity styles (the `gl` popup) resolve to the
226    /// SAME element colours the gutter glyph + underline use, and the four
227    /// severities are distinct.
228    #[test]
229    fn resolve_diagnostic_styles_match_severity_elements_and_differ() {
230        use crate::style::Style as S;
231        let (resolved, ids) = defaults();
232        let err = resolve_syntax_style(&resolved, &ids, S::DiagnosticError);
233        let warn = resolve_syntax_style(&resolved, &ids, S::DiagnosticWarning);
234        let info = resolve_syntax_style(&resolved, &ids, S::DiagnosticInfo);
235        let hint = resolve_syntax_style(&resolved, &ids, S::DiagnosticHint);
236        // Identical to the elements the gutter/underline already use.
237        assert_eq!(err, resolved.get(ids.diagnostic_error));
238        assert_eq!(warn, resolved.get(ids.diagnostic_warning));
239        // Severities are visually distinct (error ≠ info).
240        assert!(err.fg.is_some());
241        assert_ne!(err.fg, info.fg);
242        assert_ne!(warn.fg, hint.fg);
243    }
244}