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
6use color_eyre::eyre::eyre;
7use nvim_oxi::Dictionary;
8use nvim_oxi::api::SuperIterator;
9use nvim_oxi::api::opts::GetHighlightOpts;
10use nvim_oxi::api::opts::GetHighlightOptsBuilder;
11use nvim_oxi::api::opts::SetHighlightOpts;
12use nvim_oxi::api::opts::SetHighlightOptsBuilder;
13use nvim_oxi::api::types::GetHlInfos;
14use nvim_oxi::api::types::HighlightInfos;
15
16const GLOBAL_BG: &str = "#002020";
17const GLOBAL_FG: &str = "#dcdcd7";
18
19const CURSOR_BG: &str = "white";
20const CURSOR_FG: &str = "black";
21const NON_TEXT_FG: &str = "NvimDarkGrey4";
22const COMMENTS_FG: &str = "NvimLightGrey4";
23const NONE: &str = "none";
24
25const DIAG_ERROR_FG: &str = "#ec635c";
26const DIAG_OK_FG: &str = "#8ce479";
27const DIAG_WARN_FG: &str = "#ffaa33";
28const DIAG_HINT_FG: &str = "NvimLightGrey3";
29const DIAG_INFO_FG: &str = "white";
30
31const GITSIGNS_ADDED: &str = DIAG_OK_FG;
32const GITSIGNS_CHANGED: &str = "#6a6adf";
33const GITSIGNS_REMOVED: &str = DIAG_ERROR_FG;
34
35const TREESITTER_CONTEXT_BG: &str = "NvimDarkGrey3";
36
37const DIAGNOSTICS_FG: [(&str, &str); 5] = [
38    ("Error", DIAG_ERROR_FG),
39    ("Warn", DIAG_WARN_FG),
40    ("Ok", DIAG_OK_FG),
41    ("Hint", DIAG_HINT_FG),
42    ("Info", DIAG_INFO_FG),
43];
44
45const GITSIGNS_FG: [(&str, &str); 3] = [
46    ("Added", GITSIGNS_ADDED),
47    ("Changed", GITSIGNS_CHANGED),
48    ("Removed", GITSIGNS_REMOVED),
49];
50
51/// [`Dictionary`] with colorscheme and highlight helpers.
52pub fn dict() -> Dictionary {
53    dict! {
54        "set": fn_from!(set),
55    }
56}
57
58/// Sets the desired Neovim colorscheme and custom highlight groups.
59#[allow(clippy::needless_pass_by_value)]
60pub fn set(colorscheme: Option<String>) {
61    if let Some(cs) = colorscheme {
62        let _ = ytil_noxi::common::exec_vim_cmd("colorscheme", Some(&[cs]));
63    }
64
65    let opts = crate::vim_opts::global_scope();
66    crate::vim_opts::set("background", "dark", &opts);
67    crate::vim_opts::set("termguicolors", true, &opts);
68
69    let non_text_hl = get_default_hl_opts().foreground(NON_TEXT_FG).background(NONE).build();
70
71    for (hl_name, hl_opts) in [
72        (
73            "Cursor",
74            get_default_hl_opts()
75                .foreground(CURSOR_FG)
76                .background(CURSOR_BG)
77                .build(),
78        ),
79        ("CursorLine", get_default_hl_opts().foreground(NONE).build()),
80        ("ErrorMsg", get_default_hl_opts().foreground(DIAG_ERROR_FG).build()),
81        (
82            "MsgArea",
83            get_default_hl_opts().foreground(COMMENTS_FG).background(NONE).build(),
84        ),
85        ("LineNr", non_text_hl.clone()),
86        ("Normal", get_default_hl_opts().background(GLOBAL_BG).build()),
87        ("NormalFloat", get_default_hl_opts().background(GLOBAL_BG).build()),
88        ("StatusLine", non_text_hl),
89        (
90            "TreesitterContext",
91            get_default_hl_opts().background(TREESITTER_CONTEXT_BG).build(),
92        ),
93        (
94            "WinSeparator",
95            get_default_hl_opts().foreground(TREESITTER_CONTEXT_BG).build(),
96        ),
97        // Changing these will change the main foreground color.
98        ("@variable", get_default_hl_opts().foreground(GLOBAL_FG).build()),
99        ("Comment", get_default_hl_opts().foreground(COMMENTS_FG).build()),
100        ("Constant", get_default_hl_opts().foreground(GLOBAL_FG).build()),
101        ("Delimiter", get_default_hl_opts().foreground(GLOBAL_FG).build()),
102        // ("Function", get_default_hl_opts().foreground(FG).build()),
103        ("PreProc", get_default_hl_opts().foreground(GLOBAL_FG).build()),
104        ("Operator", get_default_hl_opts().foreground(GLOBAL_FG).build()),
105        (
106            "Statement",
107            get_default_hl_opts().foreground(GLOBAL_FG).bold(true).build(),
108        ),
109        ("Type", get_default_hl_opts().foreground(GLOBAL_FG).build()),
110    ] {
111        set_hl(0, hl_name, &hl_opts);
112    }
113
114    for (lvl, fg) in DIAGNOSTICS_FG {
115        // Errors are already notified by [`get_overridden_set_hl_opts`]
116        let _ = get_overridden_set_hl_opts(
117            &format!("Diagnostic{lvl}"),
118            |mut hl_opts| hl_opts.foreground(fg).background(NONE).bold(true).build(),
119            None,
120        )
121        .map(|set_hl_opts| {
122            set_hl(0, &format!("Diagnostic{lvl}"), &set_hl_opts);
123            set_hl(0, &format!("DiagnosticStatusLine{lvl}"), &set_hl_opts);
124        });
125
126        let diag_underline_hl_name = format!("DiagnosticUnderline{lvl}");
127        // Errors are already notified by [`get_overridden_set_hl_opts`]
128        let _ = get_overridden_set_hl_opts(
129            &diag_underline_hl_name,
130            |mut hl_opts| hl_opts.special(fg).background(NONE).build(),
131            None,
132        )
133        .map(|set_hl_opts| set_hl(0, &diag_underline_hl_name, &set_hl_opts));
134    }
135
136    for (hl_name, fg) in GITSIGNS_FG {
137        set_hl(0, hl_name, &get_default_hl_opts().foreground(fg).build());
138    }
139}
140
141/// Retrieves the current highlight options for a given highlight group and applies overrides.
142///
143/// This function fetches the existing highlight information for the specified `hl_name`,
144/// and then applies the provided `override_set_hl_opts` function to modify the options.
145/// This is useful for incrementally changing highlight groups based on their current state.
146///
147/// # Errors
148/// - If [`get_hl_single`] fails to retrieve the highlight info.
149/// - If [`hl_opts_from_hl_infos`] fails to convert the highlight info.
150fn get_overridden_set_hl_opts(
151    hl_name: &str,
152    override_set_hl_opts: impl FnMut(SetHighlightOptsBuilder) -> SetHighlightOpts,
153    opts_builder: Option<GetHighlightOptsBuilder>,
154) -> color_eyre::Result<SetHighlightOpts> {
155    let mut get_hl_opts = opts_builder.unwrap_or_default();
156    let hl_infos = get_hl_single(0, &get_hl_opts.name(hl_name).build())?;
157    hl_opts_from_hl_infos(&hl_infos).map(override_set_hl_opts)
158}
159
160/// Shorthand to start building [`SetHighlightOpts`].
161fn get_default_hl_opts() -> SetHighlightOptsBuilder {
162    SetHighlightOptsBuilder::default()
163}
164
165/// Sets a highlight group in the specified namespace, with error handling via Neovim notifications.
166///
167/// This function wraps [`nvim_oxi::api::set_hl`] to apply highlight options to a group.
168/// On failure, it notifies the error to Neovim instead of propagating it, ensuring
169/// the colorscheme setup continues gracefully.
170///
171/// # Errors
172/// Errors are notified to Neovim but not returned; the function always succeeds externally.
173fn set_hl(ns_id: u32, hl_name: &str, hl_opts: &SetHighlightOpts) {
174    if let Err(err) = nvim_oxi::api::set_hl(ns_id, hl_name, hl_opts) {
175        ytil_noxi::notify::error(format!(
176            "error setting highlight opts | hl_opts={hl_opts:#?} hl_name={hl_name:?} namespace={ns_id:?} error={err:#?}"
177        ));
178    }
179}
180
181/// Retrieves [`HighlightInfos`] of a single group.
182///
183/// # Errors
184/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
185/// - Returns an error in case of multiple infos ([`GetHlInfos::Map`]) for the given `hl_opts` .
186fn get_hl_single(ns_id: u32, hl_opts: &GetHighlightOpts) -> color_eyre::Result<HighlightInfos> {
187    get_hl(ns_id, hl_opts).and_then(|hl| match hl {
188        GetHlInfos::Single(highlight_infos) => Ok(highlight_infos),
189        GetHlInfos::Map(hl_infos) => Err(eyre!(
190            "multiple highlight infos returned | hl_infos={:#?} hl_opts={hl_opts:#?}",
191            hl_infos.collect::<Vec<_>>()
192        )),
193    })
194}
195
196/// Retrieves multiple [`HighlightInfos`] entries (map variant) for given highlight options.
197///
198/// Errors:
199/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
200/// - Returns an error if only a single highlight group ([`GetHlInfos::Single`]) is returned.
201#[allow(dead_code)]
202fn get_hl_multiple(
203    ns_id: u32,
204    hl_opts: &GetHighlightOpts,
205) -> color_eyre::Result<Vec<(nvim_oxi::String, HighlightInfos)>> {
206    get_hl(ns_id, hl_opts).and_then(|hl| match hl {
207        GetHlInfos::Single(hl_info) => Err(eyre!(
208            "single highlight info returned | hl_info={hl_info:#?} hl_opts={hl_opts:#?}",
209        )),
210        GetHlInfos::Map(hl_infos) => Ok(hl_infos.into_iter().collect()),
211    })
212}
213
214/// Retrieves [`GetHlInfos`] (single or map) for given highlight options.
215///
216/// # Errors
217/// - Propagates failures from [`nvim_oxi::api::get_hl`] while notifying them to Neovim.
218fn get_hl(
219    ns_id: u32,
220    hl_opts: &GetHighlightOpts,
221) -> color_eyre::Result<GetHlInfos<impl SuperIterator<(nvim_oxi::String, HighlightInfos)>>> {
222    nvim_oxi::api::get_hl(ns_id, hl_opts)
223        .inspect_err(|err| {
224            ytil_noxi::notify::error(format!(
225                "cannot get highlight infos | hl_opts={hl_opts:#?} error={err:#?}"
226            ));
227        })
228        .map_err(From::from)
229}
230
231/// Builds a [`SetHighlightOptsBuilder`] from [`HighlightInfos`], applying only present fields via [`Option::map`].
232///
233/// Returns a [`color_eyre::Result`]. Errors if `blend` (`u32`) cannot convert to `u8` and notifies it to Neovim.
234///
235/// # Errors
236/// - The `blend` value cannot fit into a `u8`.
237fn hl_opts_from_hl_infos(hl_infos: &HighlightInfos) -> color_eyre::Result<SetHighlightOptsBuilder> {
238    let mut opts = get_default_hl_opts();
239    hl_infos.altfont.map(|value| opts.altfont(value));
240    hl_infos
241        .background
242        .map(|value| opts.background(&decimal_to_hex_color(value)));
243    hl_infos.bg_indexed.map(|value| opts.bg_indexed(value));
244    hl_infos
245        .blend
246        .map(u8::try_from)
247        .transpose()
248        .inspect_err(|err| {
249            ytil_noxi::notify::error(format!(
250                "cannot convert blend value to u8 | value={:?} error={err:#?}",
251                hl_infos.blend
252            ));
253        })?
254        .map(|value| opts.blend(value));
255    hl_infos.bold.map(|value| opts.bold(value));
256    hl_infos.fallback.map(|value| opts.fallback(value));
257    hl_infos.fg_indexed.map(|value| opts.fg_indexed(value));
258    hl_infos.force.map(|value| opts.force(value));
259    hl_infos
260        .foreground
261        .map(|value| opts.foreground(&decimal_to_hex_color(value)));
262    hl_infos.italic.map(|value| opts.italic(value));
263    hl_infos.reverse.map(|value| opts.reverse(value));
264    hl_infos.special.map(|value| opts.special(&decimal_to_hex_color(value)));
265    hl_infos.standout.map(|value| opts.standout(value));
266    hl_infos.strikethrough.map(|value| opts.strikethrough(value));
267    hl_infos.undercurl.map(|value| opts.undercurl(value));
268    hl_infos.underdash.map(|value| opts.underdashed(value));
269    hl_infos.underdot.map(|value| opts.underdotted(value));
270    hl_infos.underline.map(|value| opts.underline(value));
271    Ok(opts)
272}
273
274/// Formats an RGB integer as a `#RRGGBB` hex string.
275fn decimal_to_hex_color(decimal: u32) -> String {
276    format!("#{decimal:06X}")
277}