Skip to main content

lattice_host/ui/
theme_options.rs

1// `linkme`'s distributed slices use `link_section` to aggregate
2// items at link time. The macro expansions in this file emit
3// such declarations; allow the workspace's `unsafe_code = "deny"`
4// lint locally with the same safety rationale documented in
5// `lattice-config`'s `option_decl.rs` etc.
6#![allow(unsafe_code)]
7
8//! Renderer-neutral UI / theme options.
9//!
10//! Phase 5.5.E.6: relocated from `lattice-ui-tui::tui_options` so
11//! the host can read these values directly when synchronising
12//! [`crate::ui::theme::Theme`] from config. Every option here
13//! drives the renderer-neutral [`crate::ui::theme::Theme`]; the
14//! adapter into a renderer's native style type lives in that
15//! renderer's crate.
16//!
17//! Self-registration via `linkme` -- the registry's
18//! `init_from_linkme()` walks the slice at App boot regardless of
19//! which crate emitted the entries, so linking the host crate
20//! into any renderer picks these up automatically.
21//!
22//! Storage shape: every option stores its current *primitive*
23//! value (`bool` for the toggle, `String` for the color name and
24//! the separator glyph) in the config. The renderer-specific
25//! derived style table (TUI's [`ratatui::style::Style`], GPUI's
26//! `Hsla`, ...) is kept as a cached projection on the renderer
27//! and refreshed after every `:set` cascade via the
28//! `RendererSignal::ThemeChanged` signal.
29
30use crate::ui::theme::parse_color;
31
32// Validators referenced by `#[validate(...)]` attributes below.
33fn validate_separator(s: &String) -> Result<(), String> {
34    if s.chars().count() == 1 {
35        Ok(())
36    } else {
37        Err(format!(
38            "ui.separator must be one character, got `{s}` ({} chars)",
39            s.chars().count()
40        ))
41    }
42}
43
44// `&String`, not `&str`: `#[validate(...)]` takes a `ValidateFn<T>`,
45// which is `fn(&T)` — and `T` here is the option's declared type,
46// `String`. A `&str` signature does not coerce to that fn pointer.
47#[allow(clippy::ptr_arg)]
48fn validate_color(s: &String) -> Result<(), String> {
49    parse_color(s).map(|_| ())
50}
51
52fn validate_font_size(n: &i64) -> Result<(), String> {
53    if *n >= 4 && *n <= 96 {
54        Ok(())
55    } else {
56        Err(format!("ui.font_size must be in range [4, 96], got {n}"))
57    }
58}
59
60fn validate_inactive_pane_opacity(n: &i64) -> Result<(), String> {
61    if *n >= 0 && *n <= 100 {
62        Ok(())
63    } else {
64        Err(format!(
65            "ui.inactive_pane_opacity must be in range [0, 100], got {n}"
66        ))
67    }
68}
69
70// Group binding: TUI-specific UI options under the `appearance`
71// group (theme / colors / sprite icons). User customizes via
72// `:customize appearance`.
73lattice_config::options! {
74    group = lattice_config::Appearance;
75
76    /// Apply a `DIM` overlay on inactive panes' syntax-highlighted
77    /// text so the active pane stands out without losing color.
78    #[name("ui.dim_inactive")]
79    pub UiDimInactive: bool = true;
80
81    /// Opacity of the buffer content in inactive panes, as a
82    /// percentage in `[0, 100]`. Renderers that support alpha
83    /// blending (GPUI) apply this when [`UiDimInactive`] is true;
84    /// the TUI peer ignores it (terminal cells don't have a true
85    /// alpha channel — TUI uses its `Modifier::DIM` instead).
86    ///
87    /// Default `50` (0.5 alpha) matches the contrast users see
88    /// from terminal `DIM` mode in mainstream fonts. `100` disables
89    /// the dim (same as `:set ui.dim_inactive=false`); `0` would
90    /// hide inactive panes entirely so the validator clamps the
91    /// lower bound at the option layer, not the renderer.
92    #[name("ui.inactive_pane_opacity")]
93    #[validate(validate_inactive_pane_opacity)]
94    pub UiInactivePaneOpacity: i64 = 50;
95
96    /// Wrap long lines in floating help / hover popups (`K`,
97    /// `:describe-*`, `:help`) at the popup's locked width instead
98    /// of clipping at the right edge. When disabled, lines longer
99    /// than the popup width are truncated visually (the underlying
100    /// help-buffer content is unchanged).
101    #[name("popup.wrap")]
102    pub PopupWrap: bool = true;
103
104    /// Character drawn in the column separating side-by-side
105    /// panes (default `│`).
106    #[name("ui.separator")]
107    #[validate(validate_separator)]
108    pub UiSeparator: String = String::from("│");
109
110    /// Character drawn in the row separating stacked (horizontal-
111    /// split) panes (default `─`, U+2500). Currently used only by
112    /// layouts that disable per-pane status lines; the per-pane
113    /// status line otherwise delimits horizontal splits.
114    #[name("ui.separator-horizontal")]
115    #[validate(validate_separator)]
116    pub UiSeparatorHorizontal: String = String::from("─");
117
118    /// Glyph drawn in the gutter severity column for an
119    /// Error-severity diagnostic (default `■`). One character; the
120    /// *colour* resolves through the `diagnostic.error` theme
121    /// element, not this option.
122    #[name("ui.diagnostic-error-glyph")]
123    #[validate(validate_separator)]
124    pub UiDiagnosticErrorGlyph: String = String::from("■");
125
126    /// Glyph drawn in the gutter severity column for a
127    /// Warning-severity diagnostic (default `▲`).
128    #[name("ui.diagnostic-warning-glyph")]
129    #[validate(validate_separator)]
130    pub UiDiagnosticWarningGlyph: String = String::from("▲");
131
132    /// Glyph drawn in the gutter severity column for an
133    /// Information-severity diagnostic (default `●`).
134    #[name("ui.diagnostic-info-glyph")]
135    #[validate(validate_separator)]
136    pub UiDiagnosticInfoGlyph: String = String::from("●");
137
138    /// Glyph drawn in the gutter severity column for a
139    /// Hint-severity diagnostic (default `·`).
140    #[name("ui.diagnostic-hint-glyph")]
141    #[validate(validate_separator)]
142    pub UiDiagnosticHintGlyph: String = String::from("·");
143
144    /// Foreground color of the pane separator. Accepts named ANSI
145    /// colors (red, blue, darkgray, ...) and `default` for the
146    /// terminal default.
147    #[name("ui.separator_color")]
148    #[validate(validate_color)]
149    pub UiSeparatorColor: String = String::from("darkgray");
150
151    /// Foreground color of the active pane's status line.
152    #[name("ui.statusline_active_fg")]
153    #[validate(validate_color)]
154    pub UiStatuslineActiveFg: String = String::from("default");
155
156    /// Foreground color of inactive panes' status lines.
157    #[name("ui.statusline_inactive_fg")]
158    #[validate(validate_color)]
159    pub UiStatuslineInactiveFg: String = String::from("darkgray");
160
161    /// Whether to render file-type icons as Nerd Fonts v3 glyphs.
162    ///
163    /// `true` -- expects a Nerd Font (FiraCode Nerd Font, JetBrains
164    /// Mono Nerd Font, Hack Nerd Font, ...) configured as the
165    /// terminal font; produces the rich per-language icon set.
166    ///
167    /// `false` (default) -- renders a BMP-block fallback palette
168    /// (◆ ≡ ◇ ■ ♪ ▶ ·) that works in every modern monospace font.
169    /// Pick this if you see `?` boxes in the file tree / oil.
170    #[name("ui.nerd_fonts")]
171    pub UiNerdFonts: bool = false;
172
173    /// Whether to request the terminal's keyboard-enhancement protocol
174    /// (the "kitty protocol") when the terminal reports support for it.
175    ///
176    /// `true` (default) -- push `DISAMBIGUATE_ESCAPE_CODES` when
177    /// `supports_keyboard_enhancement()` says yes. This is what makes
178    /// `<S-CR>`, `<C-CR>` and `<M-S-CR>` distinguishable from a bare
179    /// `<CR>`; without it every terminal sends the same `\r` for all
180    /// four, so those chords are unreachable however they are bound.
181    /// `lattice-protocol` has always spelled them and the GPUI peer has
182    /// always delivered them -- this closes a renderer asymmetry rather
183    /// than adding a capability.
184    ///
185    /// `false` -- never push it. Set this if a terminal answers the
186    /// support probe wrongly and keys start arriving mangled; recovering
187    /// from that should not need a rebuild.
188    ///
189    /// TUI-only. The GPUI peer gets these chords from the windowing
190    /// system and ignores this option.
191    #[name("ui.keyboard_enhancement")]
192    pub UiKeyboardEnhancement: bool = true;
193
194    /// Whether to enable OpenType ligatures in the GPUI renderer.
195    ///
196    /// `true` (default) -- shaper defaults apply (`calt`/`liga` active).
197    /// Ligature-capable fonts (Fira Code, JetBrains Mono, Cascadia
198    /// Code, Iosevka) will substitute multi-char sequences like
199    /// `->` / `!=` / `=>` with a single presentation glyph.
200    ///
201    /// `false` -- calls `FontFeatures::disable_ligatures()` before
202    /// shaping; all sequences render as individual glyphs.
203    ///
204    /// The TUI renderer ignores this option — ligatures in the
205    /// terminal are controlled by the terminal emulator's font
206    /// settings.
207    #[name("ui.ligatures")]
208    pub UiLigatures: bool = true;
209
210    /// Font family used by the GPUI (native window) renderer.
211    /// Accepts a single font family name. The font MUST be a
212    /// monospace typeface — proportional fonts produce incorrect
213    /// cursor placement because the advance-width measurement
214    /// assumes all glyphs share the same cell width.
215    ///
216    /// macOS ships "Menlo" (system monospace since 10.6). Other
217    /// common choices: "Monaco", "JetBrains Mono", "Fira Code",
218    /// "Cascadia Code", "Hack". The TUI renderer does not read
219    /// this option — its font is configured in the terminal
220    /// emulator.
221    #[name("ui.font_family")]
222    pub UiFontFamily: String = String::from("Menlo");
223
224    /// Font size in points for the GPUI (native window) renderer.
225    /// Must be a positive integer. The TUI renderer ignores this
226    /// option — font size is controlled by the terminal emulator.
227    #[name("ui.font_size")]
228    #[validate(validate_font_size)]
229    pub UiFontSize: i64 = 14;
230}
231
232#[cfg(test)]
233mod tests {
234    #![allow(clippy::unwrap_used, clippy::panic)]
235    use super::*;
236    use lattice_config::ConfigRegistry;
237
238    #[test]
239    fn type_keyed_reads_work_post_boot() {
240        let r = ConfigRegistry::new();
241        r.init_from_linkme();
242        assert!(*r.get_typed::<UiDimInactive>().unwrap());
243        assert_eq!(r.get_typed::<UiSeparator>().unwrap().as_str(), "│");
244        assert_eq!(
245            r.get_typed::<UiSeparatorColor>().unwrap().as_str(),
246            "darkgray"
247        );
248        assert_eq!(
249            r.get_typed::<UiStatuslineActiveFg>().unwrap().as_str(),
250            "default"
251        );
252        assert_eq!(
253            r.get_typed::<UiStatuslineInactiveFg>().unwrap().as_str(),
254            "darkgray"
255        );
256        // BMP fallback is the default so the first frame renders
257        // in any terminal font.
258        assert!(!*r.get_typed::<UiNerdFonts>().unwrap());
259        // T.6.t: the non-style chrome chars + diagnostic glyphs
260        // migrated off the host `Theme` struct to `ui.*` options.
261        // Defaults must match the deleted struct's literals exactly.
262        assert_eq!(
263            r.get_typed::<UiSeparatorHorizontal>().unwrap().as_str(),
264            "─"
265        );
266        assert_eq!(
267            r.get_typed::<UiDiagnosticErrorGlyph>().unwrap().as_str(),
268            "■"
269        );
270        assert_eq!(
271            r.get_typed::<UiDiagnosticWarningGlyph>().unwrap().as_str(),
272            "▲"
273        );
274        assert_eq!(
275            r.get_typed::<UiDiagnosticInfoGlyph>().unwrap().as_str(),
276            "●"
277        );
278        assert_eq!(
279            r.get_typed::<UiDiagnosticHintGlyph>().unwrap().as_str(),
280            "·"
281        );
282    }
283
284    #[test]
285    fn diagnostic_glyph_options_accept_single_char_overrides() {
286        let r = ConfigRegistry::new();
287        r.init_from_linkme();
288        r.parse_and_set_command("ui.diagnostic-error-glyph=E")
289            .unwrap();
290        assert_eq!(
291            r.get_typed::<UiDiagnosticErrorGlyph>().unwrap().as_str(),
292            "E"
293        );
294        // Multi-char rejected by the shared single-char validator.
295        let err = r
296            .parse_and_set_command("ui.diagnostic-hint-glyph=hi")
297            .unwrap_err();
298        assert!(format!("{err}").contains("must be one character"));
299    }
300
301    #[test]
302    fn ui_ligatures_option_parses_and_default_is_true() {
303        let r = ConfigRegistry::new();
304        r.init_from_linkme();
305        assert!(
306            *r.get_typed::<UiLigatures>().unwrap(),
307            "ui.ligatures should default to true"
308        );
309        r.parse_and_set_command("ui.ligatures=off").unwrap();
310        assert!(!*r.get_typed::<UiLigatures>().unwrap());
311        r.parse_and_set_command("ui.ligatures=on").unwrap();
312        assert!(*r.get_typed::<UiLigatures>().unwrap());
313    }
314
315    #[test]
316    fn nerd_fonts_flag_parses_on_off() {
317        let r = ConfigRegistry::new();
318        r.init_from_linkme();
319        r.parse_and_set_command("ui.nerd_fonts=on").unwrap();
320        assert!(*r.get_typed::<UiNerdFonts>().unwrap());
321        r.parse_and_set_command("ui.nerd_fonts=off").unwrap();
322        assert!(!*r.get_typed::<UiNerdFonts>().unwrap());
323    }
324
325    #[test]
326    fn separator_validate_rejects_multi_char_strings() {
327        let r = ConfigRegistry::new();
328        r.init_from_linkme();
329        let err = r.parse_and_set_command("ui.separator=ab").unwrap_err();
330        let msg = format!("{err}");
331        assert!(msg.contains("must be one character"), "got `{msg}`");
332    }
333
334    #[test]
335    fn separator_color_validate_rejects_invalid_color_names() {
336        let r = ConfigRegistry::new();
337        r.init_from_linkme();
338        let err = r
339            .parse_and_set_command("ui.separator_color=puce")
340            .unwrap_err();
341        let msg = format!("{err}");
342        // host theme parser's error wording: "unknown color: puce".
343        assert!(msg.contains("puce"), "got `{msg}`");
344    }
345}