1#![forbid(unsafe_code)]
2pub const HEADING_FONT_SCALES: [f32; 6] = [2.0, 1.5, 1.25, 1.1, 1.05, 1.0];
6
7pub const DARK_HEADING_COLORS: [egui::Color32; 6] = [
10 egui::Color32::from_rgb(0xBD, 0x93, 0xF9), egui::Color32::from_rgb(0xFF, 0x79, 0xC6), egui::Color32::from_rgb(0x8B, 0xE9, 0xFD), egui::Color32::from_rgb(0x50, 0xFA, 0x7B), egui::Color32::from_rgb(0xF1, 0xFA, 0x8C), egui::Color32::from_rgb(0xFF, 0xB8, 0x6C), ];
17
18pub 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#[derive(Clone, Copy, Debug)]
30pub struct HeadingStyle {
31 pub font_scale: f32,
33 pub color: egui::Color32,
35}
36
37#[derive(Clone, Debug)]
39pub struct MarkdownStyle {
40 pub headings: [HeadingStyle; 6],
42 pub body_color: Option<egui::Color32>,
44 pub code_bg: Option<egui::Color32>,
46 pub blockquote_bar: Option<egui::Color32>,
48 pub link_color: Option<egui::Color32>,
50 pub hr_color: Option<egui::Color32>,
52 pub image_base_uri: String,
54}
55
56impl MarkdownStyle {
57 #[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 #[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 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 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 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 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}