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