1use crate::palette::to_color;
9use native_theme::Rgba;
10
11#[derive(Clone, Copy)]
21pub(crate) struct OverrideColors {
22 pub btn_bg: Rgba,
23 pub btn_fg: Rgba,
24 pub surface: Rgba,
25 pub foreground: Rgba,
26 pub accent_fg: Rgba,
27 pub success_fg: Rgba,
28 pub danger_fg: Rgba,
29 pub warning_fg: Rgba,
30 pub success_bg: Rgba,
31 pub danger_bg: Rgba,
32 pub warning_bg: Rgba,
33}
34
35const MIN_STATUS_CONTRAST: f32 = 4.5;
38
39fn relative_luminance(c: iced_core::Color) -> f32 {
44 let linearize = |v: f32| -> f32 {
45 let v = v.clamp(0.0, 1.0);
46 if v <= 0.04045 {
47 v / 12.92
48 } else {
49 ((v + 0.055) / 1.055).powf(2.4)
50 }
51 };
52 0.2126 * linearize(c.r) + 0.7152 * linearize(c.g) + 0.0722 * linearize(c.b)
53}
54
55fn contrast_ratio(a: iced_core::Color, b: iced_core::Color) -> f32 {
60 let la = relative_luminance(a);
61 let lb = relative_luminance(b);
62 let (lighter, darker) = if la > lb { (la, lb) } else { (lb, la) };
63 (lighter + 0.05) / (darker + 0.05)
64}
65
66fn ensure_status_contrast(fg: iced_core::Color, bg: iced_core::Color) -> iced_core::Color {
75 if contrast_ratio(fg, bg) >= MIN_STATUS_CONTRAST {
76 fg
77 } else if relative_luminance(bg) < 0.5 {
78 iced_core::Color::WHITE
79 } else {
80 iced_core::Color::BLACK
81 }
82}
83
84pub(crate) fn apply_overrides(
106 extended: &mut iced_core::theme::palette::Extended,
107 colors: &OverrideColors,
108) {
109 extended.secondary.base.color = to_color(colors.btn_bg);
110 extended.secondary.base.text = to_color(colors.btn_fg);
111 extended.background.weak.color = to_color(colors.surface);
112 extended.background.weak.text = to_color(colors.foreground);
113 extended.primary.base.text = to_color(colors.accent_fg);
114 extended.success.base.text =
115 ensure_status_contrast(to_color(colors.success_fg), to_color(colors.success_bg));
116 extended.danger.base.text =
117 ensure_status_contrast(to_color(colors.danger_fg), to_color(colors.danger_bg));
118 extended.warning.base.text =
119 ensure_status_contrast(to_color(colors.warning_fg), to_color(colors.warning_bg));
120}
121
122#[cfg(test)]
123#[allow(clippy::unwrap_used, clippy::expect_used)]
124mod tests {
125 use super::*;
126 use iced_core::theme::palette::Extended;
127 use native_theme::ThemeSpec;
128
129 fn make_extended() -> Extended {
130 let palette = iced_core::theme::Palette::DARK;
131 Extended::generate(palette)
132 }
133
134 fn make_resolved_preset(name: &str, is_dark: bool) -> native_theme::ResolvedThemeVariant {
135 ThemeSpec::preset(name)
136 .unwrap()
137 .into_variant(is_dark)
138 .unwrap()
139 .into_resolved()
140 .unwrap()
141 }
142
143 fn make_resolved(is_dark: bool) -> native_theme::ResolvedThemeVariant {
144 make_resolved_preset("catppuccin-mocha", is_dark)
145 }
146
147 fn colors_from_resolved(r: &native_theme::ResolvedThemeVariant) -> OverrideColors {
148 OverrideColors {
149 btn_bg: r.button.background_color,
150 btn_fg: r.button.font.color,
151 surface: r.defaults.surface_color,
152 foreground: r.defaults.text_color,
153 accent_fg: r.defaults.accent_text_color,
154 success_fg: r.defaults.success_text_color,
155 danger_fg: r.defaults.danger_text_color,
156 warning_fg: r.defaults.warning_text_color,
157 success_bg: r.defaults.success_color,
158 danger_bg: r.defaults.danger_color,
159 warning_bg: r.defaults.warning_color,
160 }
161 }
162
163 fn apply_from_resolved(ext: &mut Extended, r: &native_theme::ResolvedThemeVariant) {
164 apply_overrides(ext, &colors_from_resolved(r));
165 }
166
167 #[test]
168 fn apply_overrides_sets_secondary_base_color() {
169 let mut extended = make_extended();
170 let resolved = make_resolved(false);
171
172 apply_from_resolved(&mut extended, &resolved);
173
174 let expected = to_color(resolved.button.background_color);
175 assert_eq!(
176 extended.secondary.base.color, expected,
177 "secondary.base.color should match resolved.button.background"
178 );
179 }
180
181 #[test]
182 fn apply_overrides_sets_secondary_base_text() {
183 let mut extended = make_extended();
184 let resolved = make_resolved(false);
185
186 apply_from_resolved(&mut extended, &resolved);
187
188 let expected = to_color(resolved.button.font.color);
189 assert_eq!(
190 extended.secondary.base.text, expected,
191 "secondary.base.text should match resolved.button.foreground"
192 );
193 }
194
195 #[test]
196 fn apply_overrides_sets_background_weak_color() {
197 let mut extended = make_extended();
198 let resolved = make_resolved(false);
199
200 apply_from_resolved(&mut extended, &resolved);
201
202 let expected = to_color(resolved.defaults.surface_color);
203 assert_eq!(
204 extended.background.weak.color, expected,
205 "background.weak.color should match resolved.defaults.surface"
206 );
207 }
208
209 #[test]
210 fn apply_overrides_sets_background_weak_text() {
211 let mut extended = make_extended();
212 let resolved = make_resolved(false);
213
214 apply_from_resolved(&mut extended, &resolved);
215
216 let expected = to_color(resolved.defaults.text_color);
217 assert_eq!(
218 extended.background.weak.text, expected,
219 "background.weak.text should match resolved.defaults.text_color"
220 );
221 }
222
223 #[test]
224 fn apply_overrides_sets_primary_base_text() {
225 let mut extended = make_extended();
226 let resolved = make_resolved(false);
227
228 apply_from_resolved(&mut extended, &resolved);
229
230 let expected = to_color(resolved.defaults.accent_text_color);
231 assert_eq!(
232 extended.primary.base.text, expected,
233 "primary.base.text should match resolved.defaults.accent_foreground"
234 );
235 }
236
237 #[test]
238 fn apply_overrides_sets_success_base_text() {
239 let mut extended = make_extended();
240 let resolved = make_resolved(false);
241
242 apply_from_resolved(&mut extended, &resolved);
243
244 let expected = super::ensure_status_contrast(
245 to_color(resolved.defaults.success_text_color),
246 to_color(resolved.defaults.success_color),
247 );
248 assert_eq!(
249 extended.success.base.text, expected,
250 "success.base.text should match contrast-enforced success foreground"
251 );
252 }
253
254 #[test]
255 fn apply_overrides_sets_danger_base_text() {
256 let mut extended = make_extended();
257 let resolved = make_resolved(false);
258
259 apply_from_resolved(&mut extended, &resolved);
260
261 let expected = super::ensure_status_contrast(
265 to_color(resolved.defaults.danger_text_color),
266 to_color(resolved.defaults.danger_color),
267 );
268 assert_eq!(
269 extended.danger.base.text, expected,
270 "danger.base.text should match contrast-enforced danger foreground"
271 );
272 }
273
274 #[test]
275 fn apply_overrides_sets_warning_base_text() {
276 let mut extended = make_extended();
277 let resolved = make_resolved(false);
278
279 apply_from_resolved(&mut extended, &resolved);
280
281 let expected = super::ensure_status_contrast(
282 to_color(resolved.defaults.warning_text_color),
283 to_color(resolved.defaults.warning_color),
284 );
285 assert_eq!(
286 extended.warning.base.text, expected,
287 "warning.base.text should match contrast-enforced warning foreground"
288 );
289 }
290
291 #[test]
292 fn apply_overrides_dark_variant() {
293 let mut extended = make_extended();
294 let resolved = make_resolved(true);
295
296 apply_from_resolved(&mut extended, &resolved);
297
298 let expected = to_color(resolved.button.background_color);
299 assert_eq!(
300 extended.secondary.base.color, expected,
301 "dark variant: secondary.base.color should match"
302 );
303 }
304
305 #[test]
306 fn apply_overrides_multiple_presets() {
307 for name in ["catppuccin-mocha", "dracula", "nord"] {
308 let resolved = ThemeSpec::preset(name)
309 .unwrap()
310 .into_variant(true)
311 .unwrap()
312 .into_resolved()
313 .unwrap();
314 let mut extended = make_extended();
315 apply_from_resolved(&mut extended, &resolved);
316
317 assert_eq!(
318 extended.secondary.base.color,
319 to_color(resolved.button.background_color),
320 "{name}: secondary.base.color mismatch"
321 );
322 }
323 }
324
325 #[test]
326 fn apply_overrides_with_adwaita() {
327 let resolved = make_resolved_preset("adwaita", false);
328 let mut extended = make_extended();
329 apply_from_resolved(&mut extended, &resolved);
330
331 assert_eq!(
332 extended.secondary.base.color,
333 to_color(resolved.button.background_color),
334 "adwaita: secondary.base.color mismatch"
335 );
336 assert_eq!(
337 extended.primary.base.text,
338 to_color(resolved.defaults.accent_text_color),
339 "adwaita: primary.base.text mismatch"
340 );
341 }
342
343 #[test]
344 fn ensure_status_contrast_corrects_low_contrast() {
345 let dark_bg = iced_core::Color::from_rgb(0.1, 0.1, 0.1);
347 let dark_fg = iced_core::Color::from_rgb(0.15, 0.15, 0.15);
348 let result = super::ensure_status_contrast(dark_fg, dark_bg);
349 assert_eq!(result, iced_core::Color::WHITE);
351
352 let light_bg = iced_core::Color::from_rgb(0.9, 0.9, 0.9);
354 let light_fg = iced_core::Color::from_rgb(0.85, 0.85, 0.85);
355 let result = super::ensure_status_contrast(light_fg, light_bg);
356 assert_eq!(result, iced_core::Color::BLACK);
358 }
359
360 #[test]
361 fn ensure_status_contrast_preserves_sufficient() {
362 let bg = iced_core::Color::from_rgb(0.1, 0.1, 0.1);
363 let fg = iced_core::Color::WHITE;
364 let result = super::ensure_status_contrast(fg, bg);
365 assert_eq!(result, fg, "sufficient contrast should preserve original");
366 }
367
368 #[test]
369 fn contrast_ratio_black_white() {
370 let ratio = super::contrast_ratio(iced_core::Color::BLACK, iced_core::Color::WHITE);
371 assert!(
372 ratio > 20.0,
373 "black/white contrast should be ~21, got {ratio}"
374 );
375 }
376}