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).
5//!
6//! # Note on `set_hl` implementation
7//!
8//! Highlight options are set through a Lua-based path (`vim.api.nvim_set_hl`) instead of the
9//! direct C FFI binding (`nvim_oxi::api::set_hl`). This works around an ABI mismatch between
10//! `nvim-oxi`'s `SetHighlightOpts` struct layout (targeting Neovim v0.11.3) and Neovim
11//! v0.12.0-dev (`886efcb853`), where the `cterm` field in `Dict(highlight)` changed from
12//! `Object` (32 bytes) to `DictAs(highlight_cterm)` / `Dict` (24 bytes), shifting all
13//! subsequent fields and causing validation errors.
14
15use core::fmt;
16use core::fmt::Display;
17
18use nvim_oxi::Dictionary;
19use nvim_oxi::api::SuperIterator;
20use nvim_oxi::api::opts::GetHighlightOpts;
21use nvim_oxi::api::opts::GetHighlightOptsBuilder;
22use nvim_oxi::api::types::GetHlInfos;
23use nvim_oxi::api::types::HighlightInfos;
24use rootcause::report;
25
26const GLOBAL_BG: &str = "#000000";
27const GLOBAL_FG: &str = "#c9c9c9";
28
29const CURSOR_BG: &str = "white";
30const CURSOR_FG: &str = "black";
31const NON_TEXT_FG: &str = "#777777";
32const COMMENTS_FG: &str = "#777777";
33const NONE: &str = "none";
34
35const DIAG_ERROR_FG: &str = "#ec635c";
36const DIAG_OK_FG: &str = "#8ce479";
37const DIAG_WARN_FG: &str = "#ffaa33";
38const DIAG_HINT_FG: &str = "NvimLightGrey3";
39const DIAG_INFO_FG: &str = "white";
40
41const GITSIGNS_ADDED: &str = DIAG_OK_FG;
42const GITSIGNS_CHANGED: &str = "#6a6adf";
43const GITSIGNS_REMOVED: &str = DIAG_ERROR_FG;
44
45const TREESITTER_CONTEXT_BG: &str = "NvimDarkGrey3";
46
47const DIAGNOSTICS_FG: [(&str, &str); 5] = [
48    ("Error", DIAG_ERROR_FG),
49    ("Warn", DIAG_WARN_FG),
50    ("Ok", DIAG_OK_FG),
51    ("Hint", DIAG_HINT_FG),
52    ("Info", DIAG_INFO_FG),
53];
54
55const GITSIGNS_FG: [(&str, &str); 3] = [
56    ("Added", GITSIGNS_ADDED),
57    ("Changed", GITSIGNS_CHANGED),
58    ("Removed", GITSIGNS_REMOVED),
59];
60
61/// [`Dictionary`] with colorscheme and highlight helpers.
62pub fn dict() -> Dictionary {
63    dict! {
64        "set": fn_from!(set),
65    }
66}
67
68/// Sets the desired Neovim colorscheme and custom highlight groups.
69#[allow(clippy::needless_pass_by_value)]
70pub fn set(colorscheme: Option<String>) {
71    if let Some(cs) = colorscheme {
72        let _ = ytil_noxi::common::exec_vim_cmd("colorscheme", Some(&[cs]));
73    }
74
75    let opts = crate::vim_opts::global_scope();
76    crate::vim_opts::set("background", "dark", &opts);
77    crate::vim_opts::set("termguicolors", true, &opts);
78
79    let non_text_hl = LuaHlOpts::new().fg(NON_TEXT_FG).bg(NONE);
80    let statusline_hl = non_text_hl.clone().reverse(false);
81
82    for (hl_name, hl_opts) in [
83        ("Cursor", LuaHlOpts::new().fg(CURSOR_FG).bg(CURSOR_BG)),
84        ("CursorLine", LuaHlOpts::new().fg(NONE)),
85        ("ErrorMsg", LuaHlOpts::new().fg(DIAG_ERROR_FG)),
86        ("MsgArea", LuaHlOpts::new().fg(COMMENTS_FG).bg(NONE)),
87        ("LineNr", non_text_hl),
88        ("Normal", LuaHlOpts::new().bg(GLOBAL_BG)),
89        ("NormalFloat", LuaHlOpts::new().bg(GLOBAL_BG)),
90        ("StatusLine", statusline_hl.clone()),
91        ("StatusLineNC", statusline_hl),
92        ("TreesitterContext", LuaHlOpts::new().bg(TREESITTER_CONTEXT_BG)),
93        ("WinSeparator", LuaHlOpts::new().fg(TREESITTER_CONTEXT_BG)),
94        // Changing these will change the main foreground color.
95        ("@variable", LuaHlOpts::new().fg(GLOBAL_FG)),
96        ("Comment", LuaHlOpts::new().fg(COMMENTS_FG)),
97        ("Constant", LuaHlOpts::new().fg(GLOBAL_FG)),
98        ("Delimiter", LuaHlOpts::new().fg(GLOBAL_FG)),
99        // ("Function", LuaHlOpts::new().fg(FG)),
100        ("PreProc", LuaHlOpts::new().fg(GLOBAL_FG)),
101        ("Operator", LuaHlOpts::new().fg(GLOBAL_FG)),
102        ("Statement", LuaHlOpts::new().fg(GLOBAL_FG).bold(true)),
103        ("Type", LuaHlOpts::new().fg(GLOBAL_FG)),
104    ] {
105        set_hl(0, hl_name, &hl_opts);
106    }
107
108    for (lvl, fg) in DIAGNOSTICS_FG {
109        // Errors are already notified by [`get_overridden_hl_opts`]
110        let _ = get_overridden_hl_opts(
111            &format!("Diagnostic{lvl}"),
112            |hl_opts| hl_opts.fg(fg).bg(NONE).bold(true),
113            None,
114        )
115        .map(|hl_opts| {
116            set_hl(0, &format!("Diagnostic{lvl}"), &hl_opts);
117            set_hl(0, &format!("DiagnosticStatusLine{lvl}"), &hl_opts);
118        });
119
120        let diag_underline_hl_name = format!("DiagnosticUnderline{lvl}");
121        // Errors are already notified by [`get_overridden_hl_opts`]
122        let _ = get_overridden_hl_opts(&diag_underline_hl_name, |hl_opts| hl_opts.special(fg).bg(NONE), None)
123            .map(|hl_opts| set_hl(0, &diag_underline_hl_name, &hl_opts));
124    }
125
126    for (hl_name, fg) in GITSIGNS_FG {
127        set_hl(0, hl_name, &LuaHlOpts::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(LuaHlOpts) -> LuaHlOpts,
142    opts_builder: Option<GetHighlightOptsBuilder>,
143) -> rootcause::Result<LuaHlOpts> {
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(LuaHlOpts::from(&hl_infos)))
147}
148
149/// Sets a highlight group in the specified namespace via Lua, bypassing the broken C FFI path.
150///
151/// Executes `vim.api.nvim_set_hl(ns_id, hl_name, opts)` through Neovim's Lua interpreter.
152/// On failure, it notifies the error to Neovim instead of propagating it, ensuring
153/// the colorscheme setup continues gracefully.
154fn set_hl(ns_id: u32, hl_name: &str, hl_opts: &LuaHlOpts) {
155    let lua_cmd = format!("lua vim.api.nvim_set_hl({ns_id}, '{hl_name}', {hl_opts})",);
156
157    if let Err(err) = nvim_oxi::api::command(&lua_cmd) {
158        ytil_noxi::notify::error(format!(
159            "error setting highlight opts | lua_cmd={lua_cmd:?} error={err:#?}"
160        ));
161    }
162}
163
164/// Retrieves [`HighlightInfos`] of a single group.
165///
166/// # Errors
167/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
168/// - Returns an error in case of multiple infos ([`GetHlInfos::Map`]) for the given `hl_opts` .
169fn get_hl_single(ns_id: u32, hl_opts: &GetHighlightOpts) -> rootcause::Result<HighlightInfos> {
170    get_hl(ns_id, hl_opts).and_then(|hl| match hl {
171        GetHlInfos::Single(highlight_infos) => Ok(highlight_infos),
172        GetHlInfos::Map(hl_infos) => Err(report!(
173            "multiple highlight infos returned | hl_infos={:#?} hl_opts={hl_opts:#?}",
174            hl_infos.collect::<Vec<_>>()
175        )),
176    })
177}
178
179/// Retrieves multiple [`HighlightInfos`] entries (map variant) for given highlight options.
180///
181/// Errors:
182/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
183/// - Returns an error if only a single highlight group ([`GetHlInfos::Single`]) is returned.
184#[allow(dead_code)]
185fn get_hl_multiple(
186    ns_id: u32,
187    hl_opts: &GetHighlightOpts,
188) -> rootcause::Result<Vec<(nvim_oxi::String, HighlightInfos)>> {
189    get_hl(ns_id, hl_opts).and_then(|hl| match hl {
190        GetHlInfos::Single(hl_info) => Err(report!(
191            "single highlight info returned | hl_info={hl_info:#?} hl_opts={hl_opts:#?}",
192        )),
193        GetHlInfos::Map(hl_infos) => Ok(hl_infos.into_iter().collect()),
194    })
195}
196
197/// Retrieves [`GetHlInfos`] (single or map) for given highlight options.
198///
199/// # Errors
200/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
201fn get_hl(
202    ns_id: u32,
203    hl_opts: &GetHighlightOpts,
204) -> rootcause::Result<GetHlInfos<impl SuperIterator<(nvim_oxi::String, HighlightInfos)>>> {
205    nvim_oxi::api::get_hl(ns_id, hl_opts)
206        .inspect_err(|err| {
207            ytil_noxi::notify::error(format!(
208                "cannot get highlight infos | hl_opts={hl_opts:#?} error={err:#?}"
209            ));
210        })
211        .map_err(From::from)
212}
213
214/// Highlight options that serialize to a Lua table literal for use with
215/// `vim.api.nvim_set_hl()`.
216///
217/// This bypasses the `nvim-oxi` C FFI struct (`SetHighlightOpts`) whose layout
218/// diverges from Neovim master (see module-level docs).
219#[derive(Clone, Debug, Default)]
220struct LuaHlOpts {
221    foreground: Option<String>,
222    background: Option<String>,
223    special_color: Option<String>,
224    bold: Option<bool>,
225    italic: Option<bool>,
226    reverse: Option<bool>,
227    standout: Option<bool>,
228    strikethrough: Option<bool>,
229    underline: Option<bool>,
230    undercurl: Option<bool>,
231    underdouble: Option<bool>,
232    underdotted: Option<bool>,
233    underdashed: Option<bool>,
234    altfont: Option<bool>,
235    nocombine: Option<bool>,
236    fallback: Option<bool>,
237    fg_indexed: Option<bool>,
238    bg_indexed: Option<bool>,
239    force: Option<bool>,
240    blend: Option<u32>,
241}
242
243impl LuaHlOpts {
244    fn new() -> Self {
245        Self::default()
246    }
247
248    fn fg(mut self, color: &str) -> Self {
249        self.foreground = Some(color.to_owned());
250        self
251    }
252
253    fn bg(mut self, color: &str) -> Self {
254        self.background = Some(color.to_owned());
255        self
256    }
257
258    fn special(mut self, color: &str) -> Self {
259        self.special_color = Some(color.to_owned());
260        self
261    }
262
263    const fn bold(mut self, value: bool) -> Self {
264        self.bold = Some(value);
265        self
266    }
267
268    const fn reverse(mut self, value: bool) -> Self {
269        self.reverse = Some(value);
270        self
271    }
272}
273
274impl From<&HighlightInfos> for LuaHlOpts {
275    fn from(infos: &HighlightInfos) -> Self {
276        let mut opts = Self::new();
277        if let Some(v) = infos.foreground {
278            opts.foreground = Some(decimal_to_hex_color(v));
279        }
280        if let Some(v) = infos.background {
281            opts.background = Some(decimal_to_hex_color(v));
282        }
283        if let Some(v) = infos.special {
284            opts.special_color = Some(decimal_to_hex_color(v));
285        }
286        opts.bold = infos.bold;
287        opts.italic = infos.italic;
288        opts.reverse = infos.reverse;
289        opts.standout = infos.standout;
290        opts.strikethrough = infos.strikethrough;
291        opts.underline = infos.underline;
292        opts.undercurl = infos.undercurl;
293        opts.underdouble = infos.underlineline;
294        opts.underdotted = infos.underdot;
295        opts.underdashed = infos.underdash;
296        opts.altfont = infos.altfont;
297        opts.fallback = infos.fallback;
298        opts.fg_indexed = infos.fg_indexed;
299        opts.bg_indexed = infos.bg_indexed;
300        opts.force = infos.force;
301        opts.blend = infos.blend;
302        opts
303    }
304}
305
306impl Display for LuaHlOpts {
307    /// Renders the options as a Lua table literal, e.g. `{ fg = 'black', bg = 'white', bold = true }`.
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        let mut entries: Vec<String> = Vec::new();
310
311        for (key, val) in [
312            ("fg", &self.foreground),
313            ("bg", &self.background),
314            ("sp", &self.special_color),
315        ] {
316            if let Some(v) = val {
317                entries.push(format!("{key} = '{v}'"));
318            }
319        }
320
321        for (key, val) in [
322            ("bold", self.bold),
323            ("italic", self.italic),
324            ("reverse", self.reverse),
325            ("standout", self.standout),
326            ("strikethrough", self.strikethrough),
327            ("underline", self.underline),
328            ("undercurl", self.undercurl),
329            ("underdouble", self.underdouble),
330            ("underdotted", self.underdotted),
331            ("underdashed", self.underdashed),
332            ("altfont", self.altfont),
333            ("nocombine", self.nocombine),
334            ("fallback", self.fallback),
335            ("fg_indexed", self.fg_indexed),
336            ("bg_indexed", self.bg_indexed),
337            ("force", self.force),
338        ] {
339            if let Some(v) = val {
340                entries.push(format!("{key} = {v}"));
341            }
342        }
343
344        if let Some(v) = self.blend {
345            entries.push(format!("blend = {v}"));
346        }
347
348        write!(f, "{{ {} }}", entries.join(", "))
349    }
350}
351
352/// Formats an RGB integer as a `#RRGGBB` hex string.
353fn decimal_to_hex_color(decimal: u32) -> String {
354    format!("#{decimal:06X}")
355}
356
357#[cfg(test)]
358mod tests {
359    use rstest::rstest;
360
361    use super::*;
362
363    #[test]
364    fn from_default_highlight_infos_produces_default_lua_hl_opts() {
365        let infos = HighlightInfos::default();
366        let opts = LuaHlOpts::from(&infos);
367        pretty_assertions::assert_eq!(opts.foreground, None);
368        pretty_assertions::assert_eq!(opts.background, None);
369        pretty_assertions::assert_eq!(opts.special_color, None);
370        pretty_assertions::assert_eq!(opts.bold, None);
371        pretty_assertions::assert_eq!(opts.blend, None);
372    }
373
374    #[rstest]
375    #[case(0x00_00_00, "#000000")]
376    #[case(0xFF_FF_FF, "#FFFFFF")]
377    #[case(0xFF_00_00, "#FF0000")]
378    #[case(0x00_20_20, "#002020")]
379    fn from_highlight_infos_converts_foreground_to_hex(#[case] rgb: u32, #[case] expected: &str) {
380        let mut infos = HighlightInfos::default();
381        infos.foreground = Some(rgb);
382        pretty_assertions::assert_eq!(LuaHlOpts::from(&infos).foreground.as_deref(), Some(expected));
383    }
384
385    #[rstest]
386    #[case(0xFF_FF_FF, "#FFFFFF")]
387    #[case(0x00_20_20, "#002020")]
388    fn from_highlight_infos_converts_background_to_hex(#[case] rgb: u32, #[case] expected: &str) {
389        let mut infos = HighlightInfos::default();
390        infos.background = Some(rgb);
391        pretty_assertions::assert_eq!(LuaHlOpts::from(&infos).background.as_deref(), Some(expected));
392    }
393
394    #[test]
395    fn from_highlight_infos_converts_special_to_hex() {
396        let mut infos = HighlightInfos::default();
397        infos.special = Some(0xFF_00_00);
398        pretty_assertions::assert_eq!(LuaHlOpts::from(&infos).special_color.as_deref(), Some("#FF0000"));
399    }
400
401    #[test]
402    fn from_highlight_infos_maps_boolean_fields() {
403        let mut infos = HighlightInfos::default();
404        infos.bold = Some(true);
405        infos.italic = Some(false);
406        infos.underline = Some(true);
407        infos.underdot = Some(true);
408        infos.underdash = Some(true);
409        infos.underlineline = Some(true);
410
411        let opts = LuaHlOpts::from(&infos);
412        pretty_assertions::assert_eq!(opts.bold, Some(true));
413        pretty_assertions::assert_eq!(opts.italic, Some(false));
414        pretty_assertions::assert_eq!(opts.underline, Some(true));
415        pretty_assertions::assert_eq!(opts.underdotted, Some(true));
416        pretty_assertions::assert_eq!(opts.underdashed, Some(true));
417        pretty_assertions::assert_eq!(opts.underdouble, Some(true));
418    }
419
420    #[test]
421    fn from_highlight_infos_maps_blend() {
422        let mut infos = HighlightInfos::default();
423        infos.blend = Some(50);
424        pretty_assertions::assert_eq!(LuaHlOpts::from(&infos).blend, Some(50));
425    }
426
427    #[rstest]
428    #[case(LuaHlOpts::new(), "{  }")]
429    #[case(LuaHlOpts::new().fg("black"), "{ fg = 'black' }")]
430    #[case(LuaHlOpts::new().bg("#000000"), "{ bg = '#000000' }")]
431    #[case(LuaHlOpts::new().fg("#000000").bg("white"), "{ fg = '#000000', bg = 'white' }")]
432    #[case(LuaHlOpts::new().bold(true), "{ bold = true }")]
433    #[case(LuaHlOpts::new().special("red"), "{ sp = 'red' }")]
434    #[case(LuaHlOpts { blend: Some(30), ..Default::default() }, "{ blend = 30 }")]
435    #[case(
436        LuaHlOpts::new().fg("black").bg("white").special("#FF0000").bold(true),
437        "{ fg = 'black', bg = 'white', sp = '#FF0000', bold = true }",
438    )]
439    fn display_renders_lua_table(#[case] opts: LuaHlOpts, #[case] expected: &str) {
440        pretty_assertions::assert_eq!(opts.to_string(), expected);
441    }
442}