1use crate::Rgba;
4use crate::model::spacing::ThemeSpacing;
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: Option<Rgba>,
50 pub foreground: Option<Rgba>,
52 pub accent: Option<Rgba>,
54 pub accent_foreground: Option<Rgba>,
56 pub surface: Option<Rgba>,
58 pub border: Option<Rgba>,
60 pub muted: Option<Rgba>,
62 pub shadow: Option<Rgba>,
64 pub link: Option<Rgba>,
66 pub selection: Option<Rgba>,
68 pub selection_foreground: Option<Rgba>,
70 pub selection_inactive: Option<Rgba>,
72 pub disabled_foreground: Option<Rgba>,
74
75 pub danger: Option<Rgba>,
78 pub danger_foreground: Option<Rgba>,
80 pub warning: Option<Rgba>,
82 pub warning_foreground: Option<Rgba>,
84 pub success: Option<Rgba>,
86 pub success_foreground: Option<Rgba>,
88 pub info: Option<Rgba>,
90 pub info_foreground: Option<Rgba>,
92
93 pub radius: Option<f32>,
96 pub radius_lg: Option<f32>,
98 pub frame_width: Option<f32>,
100 pub disabled_opacity: Option<f32>,
102 pub border_opacity: Option<f32>,
104 pub shadow_enabled: Option<bool>,
106
107 pub focus_ring_color: Option<Rgba>,
110 pub focus_ring_width: Option<f32>,
112 pub focus_ring_offset: Option<f32>,
114
115 #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
118 pub spacing: ThemeSpacing,
119
120 #[serde(default, skip_serializing_if = "IconSizes::is_empty")]
123 pub icon_sizes: IconSizes,
124
125 pub text_scaling_factor: Option<f32>,
128 pub reduce_motion: Option<bool>,
130 pub high_contrast: Option<bool>,
132 pub reduce_transparency: Option<bool>,
134}
135
136impl ThemeDefaults {
137 pub const FIELD_NAMES: &[&str] = &[
139 "font",
140 "line_height",
141 "mono_font",
142 "background",
143 "foreground",
144 "accent",
145 "accent_foreground",
146 "surface",
147 "border",
148 "muted",
149 "shadow",
150 "link",
151 "selection",
152 "selection_foreground",
153 "selection_inactive",
154 "disabled_foreground",
155 "danger",
156 "danger_foreground",
157 "warning",
158 "warning_foreground",
159 "success",
160 "success_foreground",
161 "info",
162 "info_foreground",
163 "radius",
164 "radius_lg",
165 "frame_width",
166 "disabled_opacity",
167 "border_opacity",
168 "shadow_enabled",
169 "focus_ring_color",
170 "focus_ring_width",
171 "focus_ring_offset",
172 "spacing",
173 "icon_sizes",
174 "text_scaling_factor",
175 "reduce_motion",
176 "high_contrast",
177 "reduce_transparency",
178 ];
179}
180
181impl_merge!(ThemeDefaults {
182 option {
183 line_height,
184 background, foreground, accent, accent_foreground,
185 surface, border, muted, shadow, link, selection, selection_foreground,
186 selection_inactive, disabled_foreground,
187 danger, danger_foreground, warning, warning_foreground,
188 success, success_foreground, info, info_foreground,
189 radius, radius_lg, frame_width, disabled_opacity, border_opacity,
190 shadow_enabled, focus_ring_color, focus_ring_width, focus_ring_offset,
191 text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
192 }
193 nested { font, mono_font, spacing, icon_sizes }
194});
195
196#[cfg(test)]
197#[allow(clippy::unwrap_used, clippy::expect_used)]
198mod tests {
199 use super::*;
200 use crate::Rgba;
201 use crate::model::spacing::ThemeSpacing;
202 use crate::model::{FontSpec, IconSizes};
203
204 #[test]
207 fn default_has_all_none_options() {
208 let d = ThemeDefaults::default();
209 assert!(d.background.is_none());
210 assert!(d.foreground.is_none());
211 assert!(d.accent.is_none());
212 assert!(d.accent_foreground.is_none());
213 assert!(d.surface.is_none());
214 assert!(d.border.is_none());
215 assert!(d.muted.is_none());
216 assert!(d.shadow.is_none());
217 assert!(d.link.is_none());
218 assert!(d.selection.is_none());
219 assert!(d.selection_foreground.is_none());
220 assert!(d.selection_inactive.is_none());
221 assert!(d.disabled_foreground.is_none());
222 assert!(d.danger.is_none());
223 assert!(d.danger_foreground.is_none());
224 assert!(d.warning.is_none());
225 assert!(d.warning_foreground.is_none());
226 assert!(d.success.is_none());
227 assert!(d.success_foreground.is_none());
228 assert!(d.info.is_none());
229 assert!(d.info_foreground.is_none());
230 assert!(d.radius.is_none());
231 assert!(d.radius_lg.is_none());
232 assert!(d.frame_width.is_none());
233 assert!(d.disabled_opacity.is_none());
234 assert!(d.border_opacity.is_none());
235 assert!(d.shadow_enabled.is_none());
236 assert!(d.focus_ring_color.is_none());
237 assert!(d.focus_ring_width.is_none());
238 assert!(d.focus_ring_offset.is_none());
239 assert!(d.text_scaling_factor.is_none());
240 assert!(d.reduce_motion.is_none());
241 assert!(d.high_contrast.is_none());
242 assert!(d.reduce_transparency.is_none());
243 assert!(d.line_height.is_none());
244 }
245
246 #[test]
247 fn default_nested_structs_are_all_empty() {
248 let d = ThemeDefaults::default();
249 assert!(d.font.is_empty());
250 assert!(d.mono_font.is_empty());
251 assert!(d.spacing.is_empty());
252 assert!(d.icon_sizes.is_empty());
253 }
254
255 #[test]
256 fn default_is_empty() {
257 assert!(ThemeDefaults::default().is_empty());
258 }
259
260 #[test]
261 fn not_empty_when_accent_set() {
262 let d = ThemeDefaults {
263 accent: Some(Rgba::rgb(0, 120, 215)),
264 ..Default::default()
265 };
266 assert!(!d.is_empty());
267 }
268
269 #[test]
270 fn not_empty_when_font_family_set() {
271 let d = ThemeDefaults {
272 font: FontSpec {
273 family: Some("Inter".into()),
274 ..Default::default()
275 },
276 ..Default::default()
277 };
278 assert!(!d.is_empty());
279 }
280
281 #[test]
282 fn not_empty_when_spacing_set() {
283 let d = ThemeDefaults {
284 spacing: ThemeSpacing {
285 m: Some(12.0),
286 ..Default::default()
287 },
288 ..Default::default()
289 };
290 assert!(!d.is_empty());
291 }
292
293 #[test]
296 fn font_is_plain_fontspec_not_option() {
297 let d = ThemeDefaults::default();
298 let _ = d.font.family;
300 let _ = d.font.size;
301 let _ = d.font.weight;
302 }
303
304 #[test]
305 fn mono_font_is_plain_fontspec_not_option() {
306 let d = ThemeDefaults::default();
307 let _ = d.mono_font.family;
308 }
309
310 #[test]
313 fn merge_option_overlay_wins() {
314 let mut base = ThemeDefaults {
315 accent: Some(Rgba::rgb(100, 100, 100)),
316 ..Default::default()
317 };
318 let overlay = ThemeDefaults {
319 accent: Some(Rgba::rgb(0, 120, 215)),
320 ..Default::default()
321 };
322 base.merge(&overlay);
323 assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
324 }
325
326 #[test]
327 fn merge_none_preserves_base() {
328 let mut base = ThemeDefaults {
329 accent: Some(Rgba::rgb(0, 120, 215)),
330 ..Default::default()
331 };
332 let overlay = ThemeDefaults::default();
333 base.merge(&overlay);
334 assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
335 }
336
337 #[test]
338 fn merge_font_family_preserved_when_overlay_family_none() {
339 let mut base = ThemeDefaults {
340 font: FontSpec {
341 family: Some("Noto Sans".into()),
342 size: Some(11.0),
343 weight: None,
344 },
345 ..Default::default()
346 };
347 let overlay = ThemeDefaults {
348 font: FontSpec {
349 family: None,
350 size: None,
351 weight: Some(700),
352 },
353 ..Default::default()
354 };
355 base.merge(&overlay);
356 assert_eq!(base.font.family.as_deref(), Some("Noto Sans")); assert_eq!(base.font.size, Some(11.0)); assert_eq!(base.font.weight, Some(700)); }
360
361 #[test]
362 fn merge_spacing_nested_merges_recursively() {
363 let mut base = ThemeDefaults {
364 spacing: ThemeSpacing {
365 m: Some(12.0),
366 ..Default::default()
367 },
368 ..Default::default()
369 };
370 let overlay = ThemeDefaults {
371 spacing: ThemeSpacing {
372 s: Some(6.0),
373 ..Default::default()
374 },
375 ..Default::default()
376 };
377 base.merge(&overlay);
378 assert_eq!(base.spacing.m, Some(12.0)); assert_eq!(base.spacing.s, Some(6.0)); }
381
382 #[test]
383 fn merge_icon_sizes_nested_merges_recursively() {
384 let mut base = ThemeDefaults {
385 icon_sizes: IconSizes {
386 toolbar: Some(22.0),
387 ..Default::default()
388 },
389 ..Default::default()
390 };
391 let overlay = ThemeDefaults {
392 icon_sizes: IconSizes {
393 small: Some(16.0),
394 ..Default::default()
395 },
396 ..Default::default()
397 };
398 base.merge(&overlay);
399 assert_eq!(base.icon_sizes.toolbar, Some(22.0)); assert_eq!(base.icon_sizes.small, Some(16.0)); }
402
403 #[test]
406 fn toml_round_trip_accent_and_font_family() {
407 let d = ThemeDefaults {
408 accent: Some(Rgba::rgb(0, 120, 215)),
409 font: FontSpec {
410 family: Some("Inter".into()),
411 ..Default::default()
412 },
413 ..Default::default()
414 };
415 let toml_str = toml::to_string(&d).unwrap();
416 assert!(
418 toml_str.contains("[font]"),
419 "Expected [font] section, got: {toml_str}"
420 );
421 assert!(
423 toml_str.contains("accent"),
424 "Expected accent field, got: {toml_str}"
425 );
426 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
428 assert_eq!(d, d2);
429 }
430
431 #[test]
432 fn toml_empty_sections_suppressed() {
433 let d = ThemeDefaults::default();
435 let toml_str = toml::to_string(&d).unwrap();
436 assert!(
438 !toml_str.contains("[font]"),
439 "Empty font should be suppressed: {toml_str}"
440 );
441 assert!(
442 !toml_str.contains("[mono_font]"),
443 "Empty mono_font should be suppressed: {toml_str}"
444 );
445 assert!(
446 !toml_str.contains("[spacing]"),
447 "Empty spacing should be suppressed: {toml_str}"
448 );
449 assert!(
450 !toml_str.contains("[icon_sizes]"),
451 "Empty icon_sizes should be suppressed: {toml_str}"
452 );
453 }
454
455 #[test]
456 fn toml_mono_font_sub_table() {
457 let d = ThemeDefaults {
458 mono_font: FontSpec {
459 family: Some("JetBrains Mono".into()),
460 size: Some(12.0),
461 ..Default::default()
462 },
463 ..Default::default()
464 };
465 let toml_str = toml::to_string(&d).unwrap();
466 assert!(
467 toml_str.contains("[mono_font]"),
468 "Expected [mono_font] section, got: {toml_str}"
469 );
470 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
471 assert_eq!(d, d2);
472 }
473
474 #[test]
475 fn toml_spacing_sub_table() {
476 let d = ThemeDefaults {
477 spacing: ThemeSpacing {
478 m: Some(12.0),
479 l: Some(18.0),
480 ..Default::default()
481 },
482 ..Default::default()
483 };
484 let toml_str = toml::to_string(&d).unwrap();
485 assert!(
486 toml_str.contains("[spacing]"),
487 "Expected [spacing] section, got: {toml_str}"
488 );
489 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
490 assert_eq!(d, d2);
491 }
492
493 #[test]
494 fn accessibility_fields_round_trip() {
495 let d = ThemeDefaults {
496 text_scaling_factor: Some(1.25),
497 reduce_motion: Some(true),
498 high_contrast: Some(false),
499 reduce_transparency: Some(true),
500 ..Default::default()
501 };
502 let toml_str = toml::to_string(&d).unwrap();
503 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
504 assert_eq!(d, d2);
505 }
506}