Skip to main content

nvrim/
colorscheme.rs

1//! Colorscheme and highlight group configuration helpers.
2//!
3//! Exposes a dictionary with a `set` function applying base UI preferences (dark background, termguicolors)
4//! and custom highlight groups (diagnostics, statusline, general UI).
5use nvim_oxi::Dictionary;
6use nvim_oxi::api::SuperIterator;
7use nvim_oxi::api::opts::GetHighlightOpts;
8use nvim_oxi::api::opts::GetHighlightOptsBuilder;
9use nvim_oxi::api::opts::SetHighlightOpts;
10use nvim_oxi::api::types::GetHlInfos;
11use nvim_oxi::api::types::HighlightInfos;
12use rootcause::report;
13
14const GLOBAL_BG: &str = "#001300";
15const GLOBAL_FG: &str = "#c9c9c9";
16
17const CURSOR_BG: &str = "white";
18const CURSOR_FG: &str = "black";
19const NON_TEXT_FG: &str = "#777777";
20const COMMENTS_FG: &str = "#777777";
21const NORMAL_FG: &str = "fg";
22const NONE: &str = "none";
23
24const DIAG_ERROR_FG: &str = "#ec635c";
25const DIAG_OK_FG: &str = "#8ce479";
26const DIAG_WARN_FG: &str = "#ffaa33";
27const DIAG_HINT_FG: &str = "#00ffff";
28const DIAG_INFO_FG: &str = "#00ffff";
29
30const GITSIGNS_ADDED: &str = DIAG_OK_FG;
31const GITSIGNS_CHANGED: &str = "#6a6adf";
32const GITSIGNS_REMOVED: &str = DIAG_ERROR_FG;
33
34const TREESITTER_CONTEXT_BG: &str = "NvimDarkGrey3";
35
36const DIAGNOSTICS_FG: [(&str, &str); 5] = [
37    ("Error", DIAG_ERROR_FG),
38    ("Warn", DIAG_WARN_FG),
39    ("Ok", DIAG_OK_FG),
40    ("Hint", DIAG_HINT_FG),
41    ("Info", DIAG_INFO_FG),
42];
43
44const GITSIGNS_FG: [(&str, &str); 3] = [
45    ("Added", GITSIGNS_ADDED),
46    ("Changed", GITSIGNS_CHANGED),
47    ("Removed", GITSIGNS_REMOVED),
48];
49
50/// [`Dictionary`] with colorscheme and highlight helpers.
51pub fn dict() -> Dictionary {
52    dict! {
53        "set": fn_from!(set),
54    }
55}
56
57/// Sets the desired Neovim colorscheme and custom highlight groups.
58pub fn set(colorscheme: Option<String>) {
59    if let Some(cs) = colorscheme {
60        drop(ytil_noxi::common::exec_vim_cmd("colorscheme", Some(&[cs])));
61    }
62
63    let opts = crate::vim_opts::global_scope();
64    crate::vim_opts::set("background", "dark", &opts);
65    crate::vim_opts::set("termguicolors", true, &opts);
66
67    let non_text_hl = HighlightOpts::new().fg(NON_TEXT_FG).bg(NONE);
68    let statusline_hl = non_text_hl.clone().reverse(false);
69
70    for (hl_name, hl_opts) in [
71        ("Cursor", HighlightOpts::new().fg(CURSOR_FG).bg(CURSOR_BG)),
72        ("CursorLine", HighlightOpts::new().fg(NONE)),
73        (
74            "DiagnosticUnnecessary",
75            HighlightOpts::new()
76                .fg(NORMAL_FG)
77                .bg(NONE)
78                .underline(false)
79                .undercurl(false),
80        ),
81        ("ErrorMsg", HighlightOpts::new().fg(DIAG_ERROR_FG)),
82        ("MsgArea", HighlightOpts::new().fg(COMMENTS_FG).bg(NONE)),
83        ("LineNr", non_text_hl),
84        ("Normal", HighlightOpts::new().bg(GLOBAL_BG)),
85        ("NormalFloat", HighlightOpts::new().bg(GLOBAL_BG)),
86        ("StatusLine", statusline_hl.clone()),
87        ("StatusLineNC", statusline_hl),
88        ("TreesitterContext", HighlightOpts::new().bg(TREESITTER_CONTEXT_BG)),
89        ("WinSeparator", HighlightOpts::new().fg(TREESITTER_CONTEXT_BG)),
90        // Changing these will change the main foreground color.
91        ("@variable", HighlightOpts::new().fg(GLOBAL_FG)),
92        ("Comment", HighlightOpts::new().fg(COMMENTS_FG)),
93        ("Constant", HighlightOpts::new().fg(GLOBAL_FG)),
94        ("Delimiter", HighlightOpts::new().fg(GLOBAL_FG)),
95        // ("Function", HighlightOpts::new().fg(FG)),
96        ("PreProc", HighlightOpts::new().fg(GLOBAL_FG)),
97        ("Operator", HighlightOpts::new().fg(GLOBAL_FG)),
98        ("Statement", HighlightOpts::new().fg(GLOBAL_FG).bold(true)),
99        ("Type", HighlightOpts::new().fg(GLOBAL_FG)),
100    ] {
101        set_hl(0, hl_name, &hl_opts);
102    }
103
104    for (lvl, fg) in DIAGNOSTICS_FG {
105        // Errors are already notified by [`get_overridden_hl_opts`]
106        if let Ok(hl_opts) = get_overridden_hl_opts(
107            &format!("Diagnostic{lvl}"),
108            |hl_opts| hl_opts.fg(fg).bg(NONE).bold(true),
109            None,
110        ) {
111            set_hl(0, &format!("Diagnostic{lvl}"), &hl_opts);
112            set_hl(0, &format!("DiagnosticStatusLine{lvl}"), &hl_opts);
113        }
114
115        let diag_underline_hl_name = format!("DiagnosticUnderline{lvl}");
116        // Errors are already notified by [`get_overridden_hl_opts`]
117        if let Ok(hl_opts) = get_overridden_hl_opts(
118            &diag_underline_hl_name,
119            |hl_opts| hl_opts.special(fg).bg(NONE).underline(true).undercurl(false),
120            None,
121        ) {
122            set_hl(0, &diag_underline_hl_name, &hl_opts);
123        }
124    }
125
126    for (hl_name, fg) in GITSIGNS_FG {
127        set_hl(0, hl_name, &HighlightOpts::new().fg(fg));
128    }
129}
130
131/// Retrieves the current highlight options for a given highlight group and applies overrides.
132///
133/// This function fetches the existing highlight information for the specified `hl_name`,
134/// and then applies the provided `override_hl_opts` function to modify the options.
135/// This is useful for incrementally changing highlight groups based on their current state.
136///
137/// # Errors
138/// - If [`get_hl_single`] fails to retrieve the highlight info.
139fn get_overridden_hl_opts(
140    hl_name: &str,
141    override_hl_opts: impl FnOnce(HighlightOpts) -> HighlightOpts,
142    opts_builder: Option<GetHighlightOptsBuilder>,
143) -> rootcause::Result<HighlightOpts> {
144    let mut get_hl_opts = opts_builder.unwrap_or_default();
145    let hl_infos = get_hl_single(0, &get_hl_opts.name(hl_name).build())?;
146    Ok(override_hl_opts(HighlightOpts::from(&hl_infos)))
147}
148
149/// Sets a highlight group in the specified namespace.
150fn set_hl(ns_id: u32, hl_name: &str, hl_opts: &HighlightOpts) {
151    if let Err(err) = nvim_oxi::api::set_hl(ns_id, hl_name, &hl_opts.to_set_highlight_opts()) {
152        ytil_noxi::notify::error(format!(
153            "error setting highlight opts | hl_name={hl_name:?} hl_opts={hl_opts:#?} error={err:#?}"
154        ));
155    }
156}
157
158/// Retrieves [`HighlightInfos`] of a single group.
159///
160/// # Errors
161/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
162/// - Returns an error in case of multiple infos ([`GetHlInfos::Map`]) for the given `hl_opts` .
163fn get_hl_single(ns_id: u32, hl_opts: &GetHighlightOpts) -> rootcause::Result<HighlightInfos> {
164    get_hl(ns_id, hl_opts).and_then(|hl| match hl {
165        GetHlInfos::Single(highlight_infos) => Ok(highlight_infos),
166        GetHlInfos::Map(hl_infos) => Err(report!(
167            "multiple highlight infos returned | hl_infos={:#?} hl_opts={hl_opts:#?}",
168            hl_infos.collect::<Vec<_>>()
169        )),
170    })
171}
172
173/// Retrieves multiple [`HighlightInfos`] entries (map variant) for given highlight options.
174///
175/// Errors:
176/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
177/// - Returns an error if only a single highlight group ([`GetHlInfos::Single`]) is returned.
178#[expect(dead_code, reason = "kept for debugging highlight maps")]
179fn get_hl_multiple(
180    ns_id: u32,
181    hl_opts: &GetHighlightOpts,
182) -> rootcause::Result<Vec<(nvim_oxi::String, HighlightInfos)>> {
183    get_hl(ns_id, hl_opts).and_then(|hl| match hl {
184        GetHlInfos::Single(hl_info) => Err(report!(
185            "single highlight info returned | hl_info={hl_info:#?} hl_opts={hl_opts:#?}",
186        )),
187        GetHlInfos::Map(hl_infos) => Ok(hl_infos.into_iter().collect()),
188    })
189}
190
191/// Retrieves [`GetHlInfos`] (single or map) for given highlight options.
192///
193/// # Errors
194/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
195fn get_hl(
196    ns_id: u32,
197    hl_opts: &GetHighlightOpts,
198) -> rootcause::Result<GetHlInfos<impl SuperIterator<(nvim_oxi::String, HighlightInfos)>>> {
199    nvim_oxi::api::get_hl(ns_id, hl_opts)
200        .inspect_err(|err| {
201            ytil_noxi::notify::error(format!(
202                "cannot get highlight infos | hl_opts={hl_opts:#?} error={err:#?}"
203            ));
204        })
205        .map_err(From::from)
206}
207
208/// Highlight options used by colorscheme helpers.
209#[derive(Clone, Debug, Default)]
210struct HighlightOpts {
211    foreground: Option<String>,
212    background: Option<String>,
213    special_color: Option<String>,
214    bold: Option<bool>,
215    italic: Option<bool>,
216    reverse: Option<bool>,
217    standout: Option<bool>,
218    strikethrough: Option<bool>,
219    underline: Option<bool>,
220    undercurl: Option<bool>,
221    underdouble: Option<bool>,
222    underdotted: Option<bool>,
223    underdashed: Option<bool>,
224    altfont: Option<bool>,
225    nocombine: Option<bool>,
226    fallback: Option<bool>,
227    fg_indexed: Option<bool>,
228    bg_indexed: Option<bool>,
229    force: Option<bool>,
230    blend: Option<u32>,
231}
232
233impl HighlightOpts {
234    fn new() -> Self {
235        Self::default()
236    }
237
238    fn fg(mut self, color: &str) -> Self {
239        self.foreground = Some(color.to_owned());
240        self
241    }
242
243    fn bg(mut self, color: &str) -> Self {
244        self.background = Some(color.to_owned());
245        self
246    }
247
248    fn special(mut self, color: &str) -> Self {
249        self.special_color = Some(color.to_owned());
250        self
251    }
252
253    const fn bold(mut self, value: bool) -> Self {
254        self.bold = Some(value);
255        self
256    }
257
258    const fn reverse(mut self, value: bool) -> Self {
259        self.reverse = Some(value);
260        self
261    }
262
263    const fn underline(mut self, value: bool) -> Self {
264        self.underline = Some(value);
265        self
266    }
267
268    const fn undercurl(mut self, value: bool) -> Self {
269        self.undercurl = Some(value);
270        self
271    }
272
273    fn to_set_highlight_opts(&self) -> SetHighlightOpts {
274        let mut opts = SetHighlightOpts::builder();
275
276        if let Some(v) = self.foreground.as_deref() {
277            let _ = opts.foreground(v);
278        }
279        if let Some(v) = self.background.as_deref() {
280            let _ = opts.background(v);
281        }
282        if let Some(v) = self.special_color.as_deref() {
283            let _ = opts.special(v);
284        }
285        if let Some(v) = self.bold {
286            let _ = opts.bold(v);
287        }
288        if let Some(v) = self.italic {
289            let _ = opts.italic(v);
290        }
291        if let Some(v) = self.reverse {
292            let _ = opts.reverse(v);
293        }
294        if let Some(v) = self.standout {
295            let _ = opts.standout(v);
296        }
297        if let Some(v) = self.strikethrough {
298            let _ = opts.strikethrough(v);
299        }
300        if let Some(v) = self.underline {
301            let _ = opts.underline(v);
302        }
303        if let Some(v) = self.undercurl {
304            let _ = opts.undercurl(v);
305        }
306        if let Some(v) = self.underdouble {
307            let _ = opts.underdouble(v);
308        }
309        if let Some(v) = self.underdotted {
310            let _ = opts.underdotted(v);
311        }
312        if let Some(v) = self.underdashed {
313            let _ = opts.underdashed(v);
314        }
315        if let Some(v) = self.altfont {
316            let _ = opts.altfont(v);
317        }
318        if let Some(v) = self.nocombine {
319            let _ = opts.nocombine(v);
320        }
321        if let Some(v) = self.fallback {
322            let _ = opts.fallback(v);
323        }
324        if let Some(v) = self.fg_indexed {
325            let _ = opts.fg_indexed(v);
326        }
327        if let Some(v) = self.bg_indexed {
328            let _ = opts.bg_indexed(v);
329        }
330        if let Some(v) = self.force {
331            let _ = opts.force(v);
332        }
333        if let Some(v) = self.blend {
334            let Ok(v) = v.try_into() else {
335                return opts.build();
336            };
337            let _ = opts.blend(v);
338        }
339
340        opts.build()
341    }
342}
343
344impl From<&HighlightInfos> for HighlightOpts {
345    fn from(infos: &HighlightInfos) -> Self {
346        let mut opts = Self::new();
347        if let Some(v) = infos.foreground {
348            opts.foreground = Some(decimal_to_hex_color(v));
349        }
350        if let Some(v) = infos.background {
351            opts.background = Some(decimal_to_hex_color(v));
352        }
353        if let Some(v) = infos.special {
354            opts.special_color = Some(decimal_to_hex_color(v));
355        }
356        opts.bold = infos.bold;
357        opts.italic = infos.italic;
358        opts.reverse = infos.reverse;
359        opts.standout = infos.standout;
360        opts.strikethrough = infos.strikethrough;
361        opts.underline = infos.underline;
362        opts.undercurl = infos.undercurl;
363        opts.underdouble = infos.underlineline;
364        opts.underdotted = infos.underdot;
365        opts.underdashed = infos.underdash;
366        opts.altfont = infos.altfont;
367        opts.fallback = infos.fallback;
368        opts.fg_indexed = infos.fg_indexed;
369        opts.bg_indexed = infos.bg_indexed;
370        opts.force = infos.force;
371        opts.blend = infos.blend;
372        opts
373    }
374}
375
376/// Formats an RGB integer as a `#RRGGBB` hex string.
377fn decimal_to_hex_color(decimal: u32) -> String {
378    format!("#{decimal:06X}")
379}
380
381#[cfg(test)]
382mod tests {
383    use rstest::rstest;
384
385    use super::*;
386
387    #[test]
388    fn test_from_default_highlight_infos_produces_default_highlight_opts() {
389        let infos = HighlightInfos::default();
390        let opts = HighlightOpts::from(&infos);
391        pretty_assertions::assert_eq!(opts.foreground, None);
392        pretty_assertions::assert_eq!(opts.background, None);
393        pretty_assertions::assert_eq!(opts.special_color, None);
394        pretty_assertions::assert_eq!(opts.bold, None);
395        pretty_assertions::assert_eq!(opts.blend, None);
396    }
397
398    #[rstest]
399    #[case(0x00_00_00, "#000000")]
400    #[case(0xFF_FF_FF, "#FFFFFF")]
401    #[case(0xFF_00_00, "#FF0000")]
402    #[case(0x00_20_20, "#002020")]
403    fn test_from_highlight_infos_converts_foreground_to_hex(#[case] rgb: u32, #[case] expected: &str) {
404        let mut infos = HighlightInfos::default();
405        infos.foreground = Some(rgb);
406        pretty_assertions::assert_eq!(HighlightOpts::from(&infos).foreground.as_deref(), Some(expected));
407    }
408
409    #[rstest]
410    #[case(0xFF_FF_FF, "#FFFFFF")]
411    #[case(0x00_20_20, "#002020")]
412    fn test_from_highlight_infos_converts_background_to_hex(#[case] rgb: u32, #[case] expected: &str) {
413        let mut infos = HighlightInfos::default();
414        infos.background = Some(rgb);
415        pretty_assertions::assert_eq!(HighlightOpts::from(&infos).background.as_deref(), Some(expected));
416    }
417
418    #[test]
419    fn test_from_highlight_infos_converts_special_to_hex() {
420        let mut infos = HighlightInfos::default();
421        infos.special = Some(0xFF_00_00);
422        pretty_assertions::assert_eq!(HighlightOpts::from(&infos).special_color.as_deref(), Some("#FF0000"));
423    }
424
425    #[test]
426    fn test_from_highlight_infos_maps_boolean_fields() {
427        let mut infos = HighlightInfos::default();
428        infos.bold = Some(true);
429        infos.italic = Some(false);
430        infos.underline = Some(true);
431        infos.underdot = Some(true);
432        infos.underdash = Some(true);
433        infos.underlineline = Some(true);
434
435        let opts = HighlightOpts::from(&infos);
436        pretty_assertions::assert_eq!(opts.bold, Some(true));
437        pretty_assertions::assert_eq!(opts.italic, Some(false));
438        pretty_assertions::assert_eq!(opts.underline, Some(true));
439        pretty_assertions::assert_eq!(opts.underdotted, Some(true));
440        pretty_assertions::assert_eq!(opts.underdashed, Some(true));
441        pretty_assertions::assert_eq!(opts.underdouble, Some(true));
442    }
443
444    #[test]
445    fn test_from_highlight_infos_maps_blend() {
446        let mut infos = HighlightInfos::default();
447        infos.blend = Some(50);
448        pretty_assertions::assert_eq!(HighlightOpts::from(&infos).blend, Some(50));
449    }
450
451    #[test]
452    fn test_to_set_highlight_opts_maps_present_fields() {
453        let opts = HighlightOpts::new()
454            .fg("#000000")
455            .bg("white")
456            .special("#FF0000")
457            .bold(true)
458            .reverse(false)
459            .underline(true)
460            .undercurl(false);
461
462        let mut expected = SetHighlightOpts::builder();
463        let _ = expected.foreground("#000000");
464        let _ = expected.background("white");
465        let _ = expected.special("#FF0000");
466        let _ = expected.bold(true);
467        let _ = expected.reverse(false);
468        let _ = expected.underline(true);
469        let _ = expected.undercurl(false);
470
471        pretty_assertions::assert_eq!(opts.to_set_highlight_opts(), expected.build(),);
472    }
473
474    #[test]
475    fn test_to_set_highlight_opts_maps_blend() {
476        let opts = HighlightOpts {
477            blend: Some(30),
478            ..Default::default()
479        };
480
481        let mut expected = SetHighlightOpts::builder();
482        let _ = expected.blend(30);
483
484        pretty_assertions::assert_eq!(opts.to_set_highlight_opts(), expected.build());
485    }
486
487    #[test]
488    fn test_to_set_highlight_opts_ignores_out_of_range_blend() {
489        let opts = HighlightOpts {
490            blend: Some(u32::from(u8::MAX) + 1),
491            ..Default::default()
492        };
493
494        pretty_assertions::assert_eq!(opts.to_set_highlight_opts(), SetHighlightOpts::default());
495    }
496
497    #[test]
498    fn test_to_set_highlight_opts_preserves_false_attrs() {
499        let opts = HighlightOpts::new().reverse(false).underline(false);
500
501        let mut expected = SetHighlightOpts::builder();
502        let _ = expected.reverse(false);
503        let _ = expected.underline(false);
504
505        pretty_assertions::assert_eq!(opts.to_set_highlight_opts(), expected.build());
506    }
507}