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}