Skip to main content

md_tui/util/
colors.rs

1use std::{
2    str::FromStr,
3    sync::{Arc, LazyLock, RwLock},
4};
5
6use config::{Config, ConfigBuilder, Environment, File, builder::DefaultState};
7use ratatui::style::Color;
8
9use crate::highlight::{DEFAULT_COLOR_MAP, HIGHLIGHT_NAMES};
10
11fn config_builder() -> ConfigBuilder<DefaultState> {
12    let config_dir = dirs::home_dir().unwrap();
13    let config_file = config_dir.join(".config").join("mdt").join("config.toml");
14    Config::builder().add_source(File::with_name(config_file.to_str().unwrap()).required(false))
15}
16
17#[derive(Debug, Clone, Copy)]
18pub struct ColorConfig {
19    // Inline styles
20    pub italic_color: Color,
21    pub bold_color: Color,
22    pub striketrough_color: Color,
23    pub bold_italic_color: Color,
24    pub code_fg_color: Color,
25    pub code_bg_color: Color,
26    pub link_color: Color,
27    pub link_selected_fg_color: Color,
28    pub link_selected_bg_color: Color,
29
30    // Block styles
31    pub code_block_bg_color: Color,
32    pub heading_fg_color: Color,
33    pub heading_bg_color: Color,
34    pub table_header_fg_color: Color,
35    pub table_header_bg_color: Color,
36    pub quote_bg_color: Color,
37
38    // File tree
39    pub file_tree_selected_fg_color: Color,
40    pub file_tree_page_count_color: Color,
41    pub file_tree_name_color: Color,
42    pub file_tree_path_color: Color,
43
44    // Quote markings
45    pub quote_important: Color,
46    pub quote_warning: Color,
47    pub quote_tip: Color,
48    pub quote_note: Color,
49    pub quote_caution: Color,
50    pub quote_default: Color,
51
52    pub help_bg_color: Color,
53    pub help_fg_color: Color,
54    pub help_title_color: Color,
55}
56
57#[must_use]
58pub fn read_color_config_from_file() -> ColorConfig {
59    let settings = config_builder()
60        .add_source(Environment::with_prefix("MDT").separator("_"))
61        .build()
62        .unwrap_or_default();
63
64    ColorConfig {
65        heading_bg_color: Color::from_str(
66            &settings.get::<String>("h_bg_color").unwrap_or_default(),
67        )
68        .unwrap_or(Color::Blue),
69        heading_fg_color: Color::from_str(
70            &settings.get::<String>("h_fg_color").unwrap_or_default(),
71        )
72        .unwrap_or(Color::Black),
73        italic_color: Color::from_str(&settings.get::<String>("italic_color").unwrap_or_default())
74            .unwrap_or(Color::Reset),
75        bold_color: Color::from_str(&settings.get::<String>("bold_color").unwrap_or_default())
76            .unwrap_or(Color::Reset),
77        striketrough_color: Color::from_str(
78            &settings
79                .get_string("striketrough_color")
80                .unwrap_or_default(),
81        )
82        .unwrap_or(Color::Reset),
83        quote_bg_color: Color::from_str(
84            &settings.get::<String>("quote_bg_color").unwrap_or_default(),
85        )
86        .unwrap_or(Color::Reset),
87        code_fg_color: Color::from_str(
88            &settings.get::<String>("code_fg_color").unwrap_or_default(),
89        )
90        .unwrap_or(Color::Red),
91        code_bg_color: Color::from_str(
92            &settings.get::<String>("code_bg_color").unwrap_or_default(),
93        )
94        .unwrap_or(Color::Rgb(48, 48, 48)),
95        code_block_bg_color: Color::from_str(
96            &settings
97                .get::<String>("code_block_bg_color")
98                .unwrap_or_default(),
99        )
100        .unwrap_or(Color::Rgb(48, 48, 48)),
101        link_color: Color::from_str(&settings.get::<String>("link_color").unwrap_or_default())
102            .unwrap_or(Color::Blue),
103        link_selected_fg_color: Color::from_str(
104            &settings
105                .get::<String>("link_selected_fg_color")
106                .unwrap_or_default(),
107        )
108        .unwrap_or(Color::Green),
109        link_selected_bg_color: Color::from_str(
110            &settings
111                .get::<String>("link_selected_bg_color")
112                .unwrap_or_default(),
113        )
114        .unwrap_or(Color::DarkGray),
115        table_header_fg_color: Color::from_str(
116            &settings
117                .get::<String>("table_header_fg_color")
118                .unwrap_or_default(),
119        )
120        .unwrap_or(Color::Yellow),
121        table_header_bg_color: Color::from_str(
122            &settings
123                .get::<String>("table_header_bg_color")
124                .unwrap_or_default(),
125        )
126        .unwrap_or(Color::Reset),
127        file_tree_selected_fg_color: Color::from_str(
128            &settings
129                .get::<String>("file_tree_selected_fg_color")
130                .unwrap_or_default(),
131        )
132        .unwrap_or(Color::LightGreen),
133        file_tree_page_count_color: Color::from_str(
134            &settings
135                .get::<String>("file_tree_page_count_color")
136                .unwrap_or_default(),
137        )
138        .unwrap_or(Color::LightGreen),
139        file_tree_name_color: Color::from_str(
140            &settings
141                .get::<String>("file_tree_name_color")
142                .unwrap_or_default(),
143        )
144        .unwrap_or(Color::Blue),
145        file_tree_path_color: Color::from_str(
146            &settings
147                .get::<String>("file_tree_path_color")
148                .unwrap_or_default(),
149        )
150        .unwrap_or(Color::DarkGray),
151        bold_italic_color: Color::from_str(
152            &settings
153                .get::<String>("bold_italic_color")
154                .unwrap_or_default(),
155        )
156        .unwrap_or(Color::Reset),
157        quote_important: Color::from_str(
158            &settings
159                .get::<String>("quote_important")
160                .unwrap_or_default(),
161        )
162        .unwrap_or(Color::LightRed),
163        quote_warning: Color::from_str(
164            &settings.get::<String>("quote_warning").unwrap_or_default(),
165        )
166        .unwrap_or(Color::LightYellow),
167
168        quote_tip: Color::from_str(&settings.get::<String>("quote_tip").unwrap_or_default())
169            .unwrap_or(Color::LightGreen),
170
171        quote_note: Color::from_str(&settings.get::<String>("quote_note").unwrap_or_default())
172            .unwrap_or(Color::LightBlue),
173
174        quote_caution: Color::from_str(
175            &settings.get::<String>("quote_caution").unwrap_or_default(),
176        )
177        .unwrap_or(Color::LightMagenta),
178
179        quote_default: Color::from_str(
180            &settings.get::<String>("quote_default").unwrap_or_default(),
181        )
182        .unwrap_or(Color::White),
183
184        help_bg_color: Color::from_str(
185            &settings.get::<String>("help_bg_color").unwrap_or_default(),
186        )
187        .unwrap_or(Color::Black),
188
189        help_fg_color: Color::from_str(
190            &settings.get::<String>("help_fg_color").unwrap_or_default(),
191        )
192        .unwrap_or(Color::LightGreen),
193
194        help_title_color: Color::from_str(
195            &settings
196                .get::<String>("help_title_color")
197                .unwrap_or_default(),
198        )
199        .unwrap_or(Color::LightGreen),
200    }
201}
202
203static COLOR_CONFIG_INTERNAL: LazyLock<Arc<RwLock<ColorConfig>>> =
204    LazyLock::new(|| Arc::new(RwLock::new(read_color_config_from_file())));
205
206pub fn set_color_config(config: ColorConfig) {
207    let mut color_config_internal = COLOR_CONFIG_INTERNAL.write().unwrap();
208    *color_config_internal = config;
209}
210
211#[must_use]
212pub fn color_config() -> ColorConfig {
213    *COLOR_CONFIG_INTERNAL.read().unwrap()
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub struct HighlightColors([Color; HIGHLIGHT_NAMES.len()]);
218
219impl std::ops::Index<usize> for HighlightColors {
220    type Output = Color;
221
222    fn index(&self, index: usize) -> &Self::Output {
223        &self.0[index]
224    }
225}
226
227/// Allow overriding with a `code_hl_`-prefixed key, dots replaced by `_`
228#[must_use]
229pub fn highlight_colors_from_settings(settings: &Config) -> HighlightColors {
230    let mut colors = DEFAULT_COLOR_MAP;
231
232    for (color, name) in colors.iter_mut().zip(HIGHLIGHT_NAMES) {
233        let key = format!("code_hl_{}", name.replace('.', "_"));
234        if let Ok(value) = settings.get::<String>(&key)
235            && let Ok(parsed) = Color::from_str(&value)
236        {
237            *color = parsed;
238        }
239    }
240
241    HighlightColors(colors)
242}
243
244#[must_use]
245pub fn read_highlight_colors_from_file() -> HighlightColors {
246    let settings = config_builder()
247        .add_source(Environment::with_prefix("MDT").separator("_"))
248        .build()
249        .unwrap_or_default();
250
251    highlight_colors_from_settings(&settings)
252}
253
254static HIGHLIGHT_COLORS_INTERNAL: LazyLock<Arc<RwLock<HighlightColors>>> =
255    LazyLock::new(|| Arc::new(RwLock::new(read_highlight_colors_from_file())));
256
257pub fn set_highlight_colors(colors: HighlightColors) {
258    let mut highlight_colors_internal = HIGHLIGHT_COLORS_INTERNAL.write().unwrap();
259    *highlight_colors_internal = colors;
260}
261
262#[must_use]
263pub fn highlight_colors() -> HighlightColors {
264    *HIGHLIGHT_COLORS_INTERNAL.read().unwrap()
265}
266
267#[derive(Clone, Copy)]
268pub struct HeadingColors {
269    pub level_2: Color,
270    pub level_3: Color,
271    pub level_4: Color,
272    pub level_5: Color,
273    pub level_6: Color,
274}
275
276#[must_use]
277pub fn read_heading_colors_from_file() -> HeadingColors {
278    let settings = config_builder().build().unwrap_or_default();
279
280    HeadingColors {
281        level_2: settings
282            .get::<String>("h2_fg_color")
283            .map(|s| Color::from_str(&s).unwrap_or(Color::Green))
284            .unwrap_or(Color::Green),
285        level_3: settings
286            .get_string("h3_fg_color")
287            .map(|s| Color::from_str(&s).unwrap_or(Color::Magenta))
288            .unwrap_or(Color::Magenta),
289        level_4: settings
290            .get_string("h4_fg_color")
291            .map(|s| Color::from_str(&s).unwrap_or(Color::Cyan))
292            .unwrap_or(Color::Cyan),
293        level_5: settings
294            .get_string("h5_fg_color")
295            .map(|s| Color::from_str(&s).unwrap_or(Color::Yellow))
296            .unwrap_or(Color::Yellow),
297        level_6: settings
298            .get_string("h6_fg_color")
299            .map(|s| Color::from_str(&s).unwrap_or(Color::LightRed))
300            .unwrap_or(Color::LightRed),
301    }
302}
303
304static HEADING_COLORS_INTERNAL: LazyLock<Arc<RwLock<HeadingColors>>> =
305    LazyLock::new(|| Arc::new(RwLock::new(read_heading_colors_from_file())));
306
307pub fn set_heading_colors(config: HeadingColors) {
308    let mut heading_colors_internal = HEADING_COLORS_INTERNAL.write().unwrap();
309    *heading_colors_internal = config;
310}
311
312#[must_use]
313pub fn heading_colors() -> HeadingColors {
314    *HEADING_COLORS_INTERNAL.read().unwrap()
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use config::FileFormat;
321
322    fn settings_from(toml: &str) -> Config {
323        Config::builder()
324            .add_source(File::from_str(toml, FileFormat::Toml))
325            .build()
326            .unwrap()
327    }
328
329    fn index_of(name: &str) -> usize {
330        HIGHLIGHT_NAMES
331            .iter()
332            .position(|n| *n == name)
333            .expect("unknown highlight name")
334    }
335
336    #[test]
337    fn defaults_match_the_built_in_palette() {
338        let colors = highlight_colors_from_settings(&settings_from(""));
339
340        for (i, default) in DEFAULT_COLOR_MAP.iter().enumerate() {
341            assert_eq!(colors[i], *default);
342        }
343    }
344
345    #[test]
346    fn a_key_overrides_a_single_capture() {
347        let colors =
348            highlight_colors_from_settings(&settings_from(r##"code_hl_keyword = "#123123""##));
349
350        assert_eq!(colors[index_of("keyword")], Color::Rgb(0x12, 0x31, 0x23));
351        assert_eq!(
352            colors[index_of("string")],
353            DEFAULT_COLOR_MAP[index_of("string")]
354        );
355    }
356
357    #[test]
358    fn dotted_captures_are_keyed_with_underscores() {
359        let colors = highlight_colors_from_settings(&settings_from(
360            r##"
361            code_hl_function_builtin = "green"
362            code_hl_punctuation_bracket = "reset"
363            code_hl_variable_parameter = "#ABCDEF"
364            "##,
365        ));
366
367        assert_eq!(colors[index_of("function.builtin")], Color::Green);
368        assert_eq!(colors[index_of("punctuation.bracket")], Color::Reset);
369        assert_eq!(
370            colors[index_of("variable.parameter")],
371            Color::Rgb(0xAB, 0xCD, 0xEF)
372        );
373    }
374
375    #[test]
376    fn unparseable_values_fall_back_to_the_default() {
377        let colors = highlight_colors_from_settings(&settings_from(
378            r#"
379            code_hl_keyword = "not-a-color"
380            code_hl_string = ""
381            "#,
382        ));
383
384        assert_eq!(
385            colors[index_of("keyword")],
386            DEFAULT_COLOR_MAP[index_of("keyword")]
387        );
388        assert_eq!(
389            colors[index_of("string")],
390            DEFAULT_COLOR_MAP[index_of("string")]
391        );
392    }
393}