1use crate::Rgba;
4use crate::model::border::BorderSpec;
5use crate::model::{FontSpec, IconSizes};
6use serde::{Deserialize, Serialize};
7
8#[serde_with::skip_serializing_none]
32#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
33#[serde(default)]
34pub struct ThemeDefaults {
35 #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
38 pub font: FontSpec,
39
40 pub line_height: Option<f32>,
42
43 #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
45 pub mono_font: FontSpec,
46
47 pub background_color: Option<Rgba>,
50 pub text_color: Option<Rgba>,
52 pub accent_color: Option<Rgba>,
54 pub accent_text_color: Option<Rgba>,
56 pub surface_color: Option<Rgba>,
58 pub muted_color: Option<Rgba>,
60 pub shadow_color: Option<Rgba>,
62 pub link_color: Option<Rgba>,
64 pub selection_background: Option<Rgba>,
66 pub selection_text_color: Option<Rgba>,
68 pub selection_inactive_background: Option<Rgba>,
70 pub text_selection_background: Option<Rgba>,
72 pub text_selection_color: Option<Rgba>,
74 pub disabled_text_color: Option<Rgba>,
76
77 pub danger_color: Option<Rgba>,
80 pub danger_text_color: Option<Rgba>,
82 pub warning_color: Option<Rgba>,
84 pub warning_text_color: Option<Rgba>,
86 pub success_color: Option<Rgba>,
88 pub success_text_color: Option<Rgba>,
90 pub info_color: Option<Rgba>,
92 pub info_text_color: Option<Rgba>,
94
95 #[serde(default, skip_serializing_if = "BorderSpec::is_empty")]
98 pub border: BorderSpec,
99 pub disabled_opacity: Option<f32>,
101
102 pub focus_ring_color: Option<Rgba>,
105 #[serde(rename = "focus_ring_width_px")]
107 pub focus_ring_width: Option<f32>,
108 #[serde(rename = "focus_ring_offset_px")]
110 pub focus_ring_offset: Option<f32>,
111
112 #[serde(default, skip_serializing_if = "IconSizes::is_empty")]
115 pub icon_sizes: IconSizes,
116
117 #[serde(skip)]
129 pub font_dpi: Option<f32>,
130
131 pub text_scaling_factor: Option<f32>,
134 pub reduce_motion: Option<bool>,
136 pub high_contrast: Option<bool>,
138 pub reduce_transparency: Option<bool>,
140}
141
142impl ThemeDefaults {
143 pub const FIELD_NAMES: &[&str] = &[
145 "font",
146 "line_height",
147 "mono_font",
148 "background_color",
149 "text_color",
150 "accent_color",
151 "accent_text_color",
152 "surface_color",
153 "muted_color",
154 "shadow_color",
155 "link_color",
156 "selection_background",
157 "selection_text_color",
158 "selection_inactive_background",
159 "text_selection_background",
160 "text_selection_color",
161 "disabled_text_color",
162 "danger_color",
163 "danger_text_color",
164 "warning_color",
165 "warning_text_color",
166 "success_color",
167 "success_text_color",
168 "info_color",
169 "info_text_color",
170 "border",
171 "disabled_opacity",
172 "focus_ring_color",
173 "focus_ring_width_px",
174 "focus_ring_offset_px",
175 "icon_sizes",
176 "text_scaling_factor",
177 "reduce_motion",
178 "high_contrast",
179 "reduce_transparency",
180 ];
181}
182
183impl_merge!(ThemeDefaults {
184 option {
185 line_height,
186 background_color, text_color, accent_color, accent_text_color,
187 surface_color, muted_color, shadow_color, link_color,
188 selection_background, selection_text_color,
189 selection_inactive_background,
190 text_selection_background, text_selection_color,
191 disabled_text_color,
192 danger_color, danger_text_color, warning_color, warning_text_color,
193 success_color, success_text_color, info_color, info_text_color,
194 disabled_opacity, focus_ring_color, focus_ring_width, focus_ring_offset,
195 font_dpi,
196 text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
197 }
198 nested { font, mono_font, border, icon_sizes }
199});
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used, clippy::expect_used)]
203mod tests {
204 use super::*;
205 use crate::Rgba;
206 use crate::model::border::BorderSpec;
207 use crate::model::font::FontSize;
208 use crate::model::{FontSpec, IconSizes};
209
210 #[test]
213 fn default_has_all_none_options() {
214 let d = ThemeDefaults::default();
215 assert!(d.background_color.is_none());
216 assert!(d.text_color.is_none());
217 assert!(d.accent_color.is_none());
218 assert!(d.accent_text_color.is_none());
219 assert!(d.surface_color.is_none());
220 assert!(d.muted_color.is_none());
221 assert!(d.shadow_color.is_none());
222 assert!(d.link_color.is_none());
223 assert!(d.selection_background.is_none());
224 assert!(d.selection_text_color.is_none());
225 assert!(d.selection_inactive_background.is_none());
226 assert!(d.text_selection_background.is_none());
227 assert!(d.text_selection_color.is_none());
228 assert!(d.disabled_text_color.is_none());
229 assert!(d.danger_color.is_none());
230 assert!(d.danger_text_color.is_none());
231 assert!(d.warning_color.is_none());
232 assert!(d.warning_text_color.is_none());
233 assert!(d.success_color.is_none());
234 assert!(d.success_text_color.is_none());
235 assert!(d.info_color.is_none());
236 assert!(d.info_text_color.is_none());
237 assert!(d.disabled_opacity.is_none());
238 assert!(d.focus_ring_color.is_none());
239 assert!(d.focus_ring_width.is_none());
240 assert!(d.focus_ring_offset.is_none());
241 assert!(d.text_scaling_factor.is_none());
242 assert!(d.reduce_motion.is_none());
243 assert!(d.high_contrast.is_none());
244 assert!(d.reduce_transparency.is_none());
245 assert!(d.line_height.is_none());
246 assert!(d.font_dpi.is_none());
247 }
248
249 #[test]
250 fn default_nested_structs_are_all_empty() {
251 let d = ThemeDefaults::default();
252 assert!(d.font.is_empty());
253 assert!(d.mono_font.is_empty());
254 assert!(d.border.is_empty());
255 assert!(d.icon_sizes.is_empty());
256 }
257
258 #[test]
259 fn default_is_empty() {
260 assert!(ThemeDefaults::default().is_empty());
261 }
262
263 #[test]
264 fn not_empty_when_accent_color_set() {
265 let d = ThemeDefaults {
266 accent_color: Some(Rgba::rgb(0, 120, 215)),
267 ..Default::default()
268 };
269 assert!(!d.is_empty());
270 }
271
272 #[test]
273 fn not_empty_when_font_family_set() {
274 let d = ThemeDefaults {
275 font: FontSpec {
276 family: Some("Inter".into()),
277 ..Default::default()
278 },
279 ..Default::default()
280 };
281 assert!(!d.is_empty());
282 }
283
284 #[test]
285 fn not_empty_when_border_set() {
286 let d = ThemeDefaults {
287 border: BorderSpec {
288 corner_radius: Some(4.0),
289 ..Default::default()
290 },
291 ..Default::default()
292 };
293 assert!(!d.is_empty());
294 }
295
296 #[test]
299 fn font_is_plain_fontspec_not_option() {
300 let d = ThemeDefaults::default();
301 let _ = d.font.family;
303 let _ = d.font.size;
304 let _ = d.font.weight;
305 }
306
307 #[test]
308 fn mono_font_is_plain_fontspec_not_option() {
309 let d = ThemeDefaults::default();
310 let _ = d.mono_font.family;
311 }
312
313 #[test]
316 fn merge_option_overlay_wins() {
317 let mut base = ThemeDefaults {
318 accent_color: Some(Rgba::rgb(100, 100, 100)),
319 ..Default::default()
320 };
321 let overlay = ThemeDefaults {
322 accent_color: Some(Rgba::rgb(0, 120, 215)),
323 ..Default::default()
324 };
325 base.merge(&overlay);
326 assert_eq!(base.accent_color, Some(Rgba::rgb(0, 120, 215)));
327 }
328
329 #[test]
330 fn merge_none_preserves_base() {
331 let mut base = ThemeDefaults {
332 accent_color: Some(Rgba::rgb(0, 120, 215)),
333 ..Default::default()
334 };
335 let overlay = ThemeDefaults::default();
336 base.merge(&overlay);
337 assert_eq!(base.accent_color, Some(Rgba::rgb(0, 120, 215)));
338 }
339
340 #[test]
341 fn merge_font_family_preserved_when_overlay_family_none() {
342 let mut base = ThemeDefaults {
343 font: FontSpec {
344 family: Some("Noto Sans".into()),
345 size: Some(FontSize::Px(11.0)),
346 weight: None,
347 ..Default::default()
348 },
349 ..Default::default()
350 };
351 let overlay = ThemeDefaults {
352 font: FontSpec {
353 family: None,
354 size: None,
355 weight: Some(700),
356 ..Default::default()
357 },
358 ..Default::default()
359 };
360 base.merge(&overlay);
361 assert_eq!(base.font.family.as_deref(), Some("Noto Sans")); assert_eq!(base.font.size, Some(FontSize::Px(11.0))); assert_eq!(base.font.weight, Some(700)); }
365
366 #[test]
367 fn merge_border_nested_merges_recursively() {
368 let mut base = ThemeDefaults {
369 border: BorderSpec {
370 corner_radius: Some(4.0),
371 ..Default::default()
372 },
373 ..Default::default()
374 };
375 let overlay = ThemeDefaults {
376 border: BorderSpec {
377 line_width: Some(1.0),
378 ..Default::default()
379 },
380 ..Default::default()
381 };
382 base.merge(&overlay);
383 assert_eq!(base.border.corner_radius, Some(4.0)); assert_eq!(base.border.line_width, Some(1.0)); }
386
387 #[test]
388 fn merge_icon_sizes_nested_merges_recursively() {
389 let mut base = ThemeDefaults {
390 icon_sizes: IconSizes {
391 toolbar: Some(22.0),
392 ..Default::default()
393 },
394 ..Default::default()
395 };
396 let overlay = ThemeDefaults {
397 icon_sizes: IconSizes {
398 small: Some(16.0),
399 ..Default::default()
400 },
401 ..Default::default()
402 };
403 base.merge(&overlay);
404 assert_eq!(base.icon_sizes.toolbar, Some(22.0)); assert_eq!(base.icon_sizes.small, Some(16.0)); }
407
408 #[test]
411 fn toml_round_trip_accent_color_and_font_family() {
412 let d = ThemeDefaults {
413 accent_color: Some(Rgba::rgb(0, 120, 215)),
414 font: FontSpec {
415 family: Some("Inter".into()),
416 ..Default::default()
417 },
418 ..Default::default()
419 };
420 let toml_str = toml::to_string(&d).unwrap();
421 assert!(
423 toml_str.contains("[font]"),
424 "Expected [font] section, got: {toml_str}"
425 );
426 assert!(
428 toml_str.contains("accent_color"),
429 "Expected accent_color field, got: {toml_str}"
430 );
431 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
433 assert_eq!(d, d2);
434 }
435
436 #[test]
437 fn toml_empty_sections_suppressed() {
438 let d = ThemeDefaults::default();
440 let toml_str = toml::to_string(&d).unwrap();
441 assert!(
443 !toml_str.contains("[font]"),
444 "Empty font should be suppressed: {toml_str}"
445 );
446 assert!(
447 !toml_str.contains("[mono_font]"),
448 "Empty mono_font should be suppressed: {toml_str}"
449 );
450 assert!(
451 !toml_str.contains("[border]"),
452 "Empty border should be suppressed: {toml_str}"
453 );
454 assert!(
455 !toml_str.contains("[icon_sizes]"),
456 "Empty icon_sizes should be suppressed: {toml_str}"
457 );
458 }
459
460 #[test]
461 fn toml_mono_font_sub_table() {
462 let d = ThemeDefaults {
463 mono_font: FontSpec {
464 family: Some("JetBrains Mono".into()),
465 size: Some(FontSize::Px(12.0)),
466 ..Default::default()
467 },
468 ..Default::default()
469 };
470 let toml_str = toml::to_string(&d).unwrap();
471 assert!(
472 toml_str.contains("[mono_font]"),
473 "Expected [mono_font] section, got: {toml_str}"
474 );
475 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
476 assert_eq!(d, d2);
477 }
478
479 #[test]
480 fn toml_border_sub_table() {
481 let d = ThemeDefaults {
482 border: BorderSpec {
483 corner_radius: Some(4.0),
484 line_width: Some(1.0),
485 ..Default::default()
486 },
487 ..Default::default()
488 };
489 let toml_str = toml::to_string(&d).unwrap();
490 assert!(
491 toml_str.contains("[border]"),
492 "Expected [border] section, got: {toml_str}"
493 );
494 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
495 assert_eq!(d, d2);
496 }
497
498 #[test]
499 fn accessibility_fields_round_trip() {
500 let d = ThemeDefaults {
501 text_scaling_factor: Some(1.25),
502 reduce_motion: Some(true),
503 high_contrast: Some(false),
504 reduce_transparency: Some(true),
505 ..Default::default()
506 };
507 let toml_str = toml::to_string(&d).unwrap();
508 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
509 assert_eq!(d, d2);
510 }
511}