Skip to main content

rustdown_md/
style.rs

1#![forbid(unsafe_code)]
2//! Configurable styles for Markdown preview rendering.
3
4/// Default heading font scales (H1-H6).
5pub const HEADING_FONT_SCALES: [f32; 6] = [2.0, 1.5, 1.25, 1.1, 1.05, 1.0];
6
7/// Dracula heading palette for dark themes, ordered from the primary
8/// accent (H1) down to warmer secondary accents (H6).
9pub const DARK_HEADING_COLORS: [egui::Color32; 6] = [
10    egui::Color32::from_rgb(0xBD, 0x93, 0xF9), // purple
11    egui::Color32::from_rgb(0xFF, 0x79, 0xC6), // pink
12    egui::Color32::from_rgb(0x8B, 0xE9, 0xFD), // cyan
13    egui::Color32::from_rgb(0x50, 0xFA, 0x7B), // green
14    egui::Color32::from_rgb(0xF1, 0xFA, 0x8C), // yellow
15    egui::Color32::from_rgb(0xFF, 0xB8, 0x6C), // orange
16];
17
18/// Light-theme companions to the Dracula heading palette.
19pub const LIGHT_HEADING_COLORS: [egui::Color32; 6] = [
20    egui::Color32::from_rgb(0x6A, 0x1B, 0x9A),
21    egui::Color32::from_rgb(0xAD, 0x14, 0x57),
22    egui::Color32::from_rgb(0x00, 0x5F, 0x9A),
23    egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
24    egui::Color32::from_rgb(0x8C, 0x6D, 0x00),
25    egui::Color32::from_rgb(0x9C, 0x3D, 0x00),
26];
27
28/// Per-heading-level style: font scale relative to body and colour.
29#[derive(Clone, Copy, Debug)]
30pub struct HeadingStyle {
31    /// Multiplier applied to body font size.
32    pub font_scale: f32,
33    /// Text colour for this heading level.
34    pub color: egui::Color32,
35}
36
37/// Full style configuration for the Markdown renderer.
38#[derive(Clone, Debug)]
39pub struct MarkdownStyle {
40    /// Heading styles for levels H1-H6 (index 0 = H1).
41    pub headings: [HeadingStyle; 6],
42    /// Body text colour (falls back to `visuals.text_color()` if `None`).
43    pub body_color: Option<egui::Color32>,
44    /// Code background tint.
45    pub code_bg: Option<egui::Color32>,
46    /// Blockquote left-border colour.
47    pub blockquote_bar: Option<egui::Color32>,
48    /// Link colour.
49    pub link_color: Option<egui::Color32>,
50    /// Horizontal rule colour.
51    pub hr_color: Option<egui::Color32>,
52    /// Base URI for resolving relative image paths (e.g. `"file:///path/to/dir/"`).
53    pub image_base_uri: String,
54}
55
56impl MarkdownStyle {
57    /// Create a default style derived from egui visuals (no heading colours).
58    #[must_use]
59    pub fn from_visuals(visuals: &egui::Visuals) -> Self {
60        let link = visuals.hyperlink_color;
61        let headings = std::array::from_fn(|i| HeadingStyle {
62            font_scale: HEADING_FONT_SCALES[i],
63            color: link,
64        });
65        Self {
66            headings,
67            body_color: None,
68            code_bg: Some(visuals.faint_bg_color),
69            blockquote_bar: Some(visuals.weak_text_color()),
70            link_color: Some(link),
71            hr_color: Some(visuals.weak_text_color()),
72            image_base_uri: String::new(),
73        }
74    }
75
76    /// Create a style with coloured headings, auto-selecting palette by theme.
77    #[must_use]
78    pub fn colored(visuals: &egui::Visuals) -> Self {
79        let mut s = Self::from_visuals(visuals);
80        let colors = if visuals.dark_mode {
81            DARK_HEADING_COLORS
82        } else {
83            LIGHT_HEADING_COLORS
84        };
85        s.set_heading_colors(colors);
86        s
87    }
88
89    /// Set heading colours from an external palette.
90    pub fn set_heading_colors(&mut self, colors: [egui::Color32; 6]) {
91        for (h, c) in self.headings.iter_mut().zip(colors) {
92            h.color = c;
93        }
94    }
95
96    /// Set heading font scales.
97    pub fn set_heading_scales(&mut self, scales: [f32; 6]) {
98        for (h, s) in self.headings.iter_mut().zip(scales) {
99            h.font_scale = s;
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn colored_dark_uses_dark_palette() {
110        let style = MarkdownStyle::colored(&egui::Visuals::dark());
111        assert_eq!(style.headings[0].color, DARK_HEADING_COLORS[0]);
112    }
113
114    #[test]
115    fn colored_light_uses_light_palette() {
116        let style = MarkdownStyle::colored(&egui::Visuals::light());
117        assert_eq!(style.headings[0].color, LIGHT_HEADING_COLORS[0]);
118    }
119
120    #[test]
121    fn default_scales_match_constant() {
122        let style = MarkdownStyle::from_visuals(&egui::Visuals::dark());
123        for (i, h) in style.headings.iter().enumerate() {
124            assert!(
125                (h.font_scale - HEADING_FONT_SCALES[i]).abs() < f32::EPSILON,
126                "heading {i} scale mismatch"
127            );
128        }
129    }
130
131    /// Two colours are "visually distinct" when at least one RGB channel
132    /// differs by ≥ 10 units.
133    fn colors_distinct(a: egui::Color32, b: egui::Color32) -> bool {
134        let dr = (i16::from(a.r()) - i16::from(b.r())).unsigned_abs();
135        let dg = (i16::from(a.g()) - i16::from(b.g())).unsigned_abs();
136        let db = (i16::from(a.b()) - i16::from(b.b())).unsigned_abs();
137        dr >= 10 || dg >= 10 || db >= 10
138    }
139
140    fn assert_palette_pairwise_distinct(palette: &[egui::Color32; 6], label: &str) {
141        for (i, a) in palette.iter().enumerate() {
142            for (j, b) in palette.iter().enumerate().skip(i + 1) {
143                assert!(
144                    colors_distinct(*a, *b),
145                    "{label} headings H{} and H{} are too similar",
146                    i + 1,
147                    j + 1,
148                );
149            }
150        }
151    }
152
153    #[test]
154    fn dark_heading_colors_are_pairwise_distinct() {
155        assert_palette_pairwise_distinct(&DARK_HEADING_COLORS, "dark");
156    }
157
158    #[test]
159    fn light_heading_colors_are_pairwise_distinct() {
160        assert_palette_pairwise_distinct(&LIGHT_HEADING_COLORS, "light");
161    }
162
163    #[test]
164    fn font_scales_are_monotonically_decreasing() {
165        for i in 1..HEADING_FONT_SCALES.len() {
166            assert!(
167                HEADING_FONT_SCALES[i] <= HEADING_FONT_SCALES[i - 1],
168                "scale H{} ({}) should be ≤ H{} ({})",
169                i + 1,
170                HEADING_FONT_SCALES[i],
171                i,
172                HEADING_FONT_SCALES[i - 1],
173            );
174        }
175    }
176
177    #[test]
178    fn body_color_differs_from_heading_colors_dark() {
179        let style = MarkdownStyle::colored(&egui::Visuals::dark());
180        let body = style
181            .body_color
182            .unwrap_or_else(|| egui::Visuals::dark().text_color());
183        for (i, h) in style.headings.iter().enumerate() {
184            assert!(
185                colors_distinct(body, h.color),
186                "dark body colour matches heading H{}",
187                i + 1,
188            );
189        }
190    }
191
192    #[test]
193    fn body_color_differs_from_heading_colors_light() {
194        let style = MarkdownStyle::colored(&egui::Visuals::light());
195        let body = style
196            .body_color
197            .unwrap_or_else(|| egui::Visuals::light().text_color());
198        for (i, h) in style.headings.iter().enumerate() {
199            assert!(
200                colors_distinct(body, h.color),
201                "light body colour matches heading H{}",
202                i + 1,
203            );
204        }
205    }
206
207    #[test]
208    fn hr_link_code_bg_are_set() {
209        for visuals in [egui::Visuals::dark(), egui::Visuals::light()] {
210            let style = MarkdownStyle::colored(&visuals);
211            assert!(style.hr_color.is_some(), "hr_color should be set");
212            assert!(style.link_color.is_some(), "link_color should be set");
213            assert!(style.code_bg.is_some(), "code_bg should be set");
214
215            let (hr, link, code_bg) = (
216                style.hr_color.unwrap_or_default(),
217                style.link_color.unwrap_or_default(),
218                style.code_bg.unwrap_or_default(),
219            );
220            assert_ne!(hr.a(), 0, "hr_color should not be transparent");
221            assert_ne!(link.a(), 0, "link_color should not be transparent");
222            // code_bg may use additive blending (alpha=0 with non-zero RGB),
223            // so just verify it is not fully black-transparent.
224            assert!(
225                code_bg.r() > 0 || code_bg.g() > 0 || code_bg.b() > 0 || code_bg.a() > 0,
226                "code_bg should not be fully invisible"
227            );
228
229            let body = visuals.text_color();
230            assert!(
231                colors_distinct(link, body),
232                "link colour should be visually distinct from body text"
233            );
234        }
235    }
236
237    #[test]
238    fn from_visuals_works_for_both_modes() {
239        let dark = MarkdownStyle::from_visuals(&egui::Visuals::dark());
240        let light = MarkdownStyle::from_visuals(&egui::Visuals::light());
241        assert_eq!(dark.headings.len(), 6);
242        assert_eq!(light.headings.len(), 6);
243        assert!(dark.code_bg.is_some());
244        assert!(light.code_bg.is_some());
245    }
246
247    #[test]
248    fn all_heading_scales_at_least_body_size() {
249        for (i, &scale) in HEADING_FONT_SCALES.iter().enumerate() {
250            assert!(
251                scale >= 1.0,
252                "H{} scale {} is smaller than body text",
253                i + 1,
254                scale
255            );
256        }
257    }
258}