1use crate::Rgba;
4use crate::model::spacing::ThemeSpacing;
5use crate::model::{FontSpec, IconSizes};
6use serde::{Deserialize, Serialize};
7
8#[serde_with::skip_serializing_none]
17#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
18#[serde(default)]
19#[non_exhaustive]
20pub struct ThemeDefaults {
21 #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
24 pub font: FontSpec,
25
26 pub line_height: Option<f32>,
28
29 #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
31 pub mono_font: FontSpec,
32
33 pub background: Option<Rgba>,
36 pub foreground: Option<Rgba>,
38 pub accent: Option<Rgba>,
40 pub accent_foreground: Option<Rgba>,
42 pub surface: Option<Rgba>,
44 pub border: Option<Rgba>,
46 pub muted: Option<Rgba>,
48 pub shadow: Option<Rgba>,
50 pub link: Option<Rgba>,
52 pub selection: Option<Rgba>,
54 pub selection_foreground: Option<Rgba>,
56 pub selection_inactive: Option<Rgba>,
58 pub disabled_foreground: Option<Rgba>,
60
61 pub danger: Option<Rgba>,
64 pub danger_foreground: Option<Rgba>,
66 pub warning: Option<Rgba>,
68 pub warning_foreground: Option<Rgba>,
70 pub success: Option<Rgba>,
72 pub success_foreground: Option<Rgba>,
74 pub info: Option<Rgba>,
76 pub info_foreground: Option<Rgba>,
78
79 pub radius: Option<f32>,
82 pub radius_lg: Option<f32>,
84 pub frame_width: Option<f32>,
86 pub disabled_opacity: Option<f32>,
88 pub border_opacity: Option<f32>,
90 pub shadow_enabled: Option<bool>,
92
93 pub focus_ring_color: Option<Rgba>,
96 pub focus_ring_width: Option<f32>,
98 pub focus_ring_offset: Option<f32>,
100
101 #[serde(default, skip_serializing_if = "ThemeSpacing::is_empty")]
104 pub spacing: ThemeSpacing,
105
106 #[serde(default, skip_serializing_if = "IconSizes::is_empty")]
109 pub icon_sizes: IconSizes,
110
111 pub text_scaling_factor: Option<f32>,
114 pub reduce_motion: Option<bool>,
116 pub high_contrast: Option<bool>,
118 pub reduce_transparency: Option<bool>,
120}
121
122impl_merge!(ThemeDefaults {
123 option {
124 line_height,
125 background, foreground, accent, accent_foreground,
126 surface, border, muted, shadow, link, selection, selection_foreground,
127 selection_inactive, disabled_foreground,
128 danger, danger_foreground, warning, warning_foreground,
129 success, success_foreground, info, info_foreground,
130 radius, radius_lg, frame_width, disabled_opacity, border_opacity,
131 shadow_enabled, focus_ring_color, focus_ring_width, focus_ring_offset,
132 text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
133 }
134 nested { font, mono_font, spacing, icon_sizes }
135});
136
137#[cfg(test)]
138#[allow(clippy::unwrap_used, clippy::expect_used)]
139mod tests {
140 use super::*;
141 use crate::Rgba;
142 use crate::model::spacing::ThemeSpacing;
143 use crate::model::{FontSpec, IconSizes};
144
145 #[test]
148 fn default_has_all_none_options() {
149 let d = ThemeDefaults::default();
150 assert!(d.background.is_none());
151 assert!(d.foreground.is_none());
152 assert!(d.accent.is_none());
153 assert!(d.accent_foreground.is_none());
154 assert!(d.surface.is_none());
155 assert!(d.border.is_none());
156 assert!(d.muted.is_none());
157 assert!(d.shadow.is_none());
158 assert!(d.link.is_none());
159 assert!(d.selection.is_none());
160 assert!(d.selection_foreground.is_none());
161 assert!(d.selection_inactive.is_none());
162 assert!(d.disabled_foreground.is_none());
163 assert!(d.danger.is_none());
164 assert!(d.danger_foreground.is_none());
165 assert!(d.warning.is_none());
166 assert!(d.warning_foreground.is_none());
167 assert!(d.success.is_none());
168 assert!(d.success_foreground.is_none());
169 assert!(d.info.is_none());
170 assert!(d.info_foreground.is_none());
171 assert!(d.radius.is_none());
172 assert!(d.radius_lg.is_none());
173 assert!(d.frame_width.is_none());
174 assert!(d.disabled_opacity.is_none());
175 assert!(d.border_opacity.is_none());
176 assert!(d.shadow_enabled.is_none());
177 assert!(d.focus_ring_color.is_none());
178 assert!(d.focus_ring_width.is_none());
179 assert!(d.focus_ring_offset.is_none());
180 assert!(d.text_scaling_factor.is_none());
181 assert!(d.reduce_motion.is_none());
182 assert!(d.high_contrast.is_none());
183 assert!(d.reduce_transparency.is_none());
184 assert!(d.line_height.is_none());
185 }
186
187 #[test]
188 fn default_nested_structs_are_all_empty() {
189 let d = ThemeDefaults::default();
190 assert!(d.font.is_empty());
191 assert!(d.mono_font.is_empty());
192 assert!(d.spacing.is_empty());
193 assert!(d.icon_sizes.is_empty());
194 }
195
196 #[test]
197 fn default_is_empty() {
198 assert!(ThemeDefaults::default().is_empty());
199 }
200
201 #[test]
202 fn not_empty_when_accent_set() {
203 let d = ThemeDefaults {
204 accent: Some(Rgba::rgb(0, 120, 215)),
205 ..Default::default()
206 };
207 assert!(!d.is_empty());
208 }
209
210 #[test]
211 fn not_empty_when_font_family_set() {
212 let d = ThemeDefaults {
213 font: FontSpec {
214 family: Some("Inter".into()),
215 ..Default::default()
216 },
217 ..Default::default()
218 };
219 assert!(!d.is_empty());
220 }
221
222 #[test]
223 fn not_empty_when_spacing_set() {
224 let d = ThemeDefaults {
225 spacing: ThemeSpacing {
226 m: Some(12.0),
227 ..Default::default()
228 },
229 ..Default::default()
230 };
231 assert!(!d.is_empty());
232 }
233
234 #[test]
237 fn font_is_plain_fontspec_not_option() {
238 let d = ThemeDefaults::default();
239 let _ = d.font.family;
241 let _ = d.font.size;
242 let _ = d.font.weight;
243 }
244
245 #[test]
246 fn mono_font_is_plain_fontspec_not_option() {
247 let d = ThemeDefaults::default();
248 let _ = d.mono_font.family;
249 }
250
251 #[test]
254 fn merge_option_overlay_wins() {
255 let mut base = ThemeDefaults {
256 accent: Some(Rgba::rgb(100, 100, 100)),
257 ..Default::default()
258 };
259 let overlay = ThemeDefaults {
260 accent: Some(Rgba::rgb(0, 120, 215)),
261 ..Default::default()
262 };
263 base.merge(&overlay);
264 assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
265 }
266
267 #[test]
268 fn merge_none_preserves_base() {
269 let mut base = ThemeDefaults {
270 accent: Some(Rgba::rgb(0, 120, 215)),
271 ..Default::default()
272 };
273 let overlay = ThemeDefaults::default();
274 base.merge(&overlay);
275 assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
276 }
277
278 #[test]
279 fn merge_font_family_preserved_when_overlay_family_none() {
280 let mut base = ThemeDefaults {
281 font: FontSpec {
282 family: Some("Noto Sans".into()),
283 size: Some(11.0),
284 weight: None,
285 },
286 ..Default::default()
287 };
288 let overlay = ThemeDefaults {
289 font: FontSpec {
290 family: None,
291 size: None,
292 weight: Some(700),
293 },
294 ..Default::default()
295 };
296 base.merge(&overlay);
297 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)); }
301
302 #[test]
303 fn merge_spacing_nested_merges_recursively() {
304 let mut base = ThemeDefaults {
305 spacing: ThemeSpacing {
306 m: Some(12.0),
307 ..Default::default()
308 },
309 ..Default::default()
310 };
311 let overlay = ThemeDefaults {
312 spacing: ThemeSpacing {
313 s: Some(6.0),
314 ..Default::default()
315 },
316 ..Default::default()
317 };
318 base.merge(&overlay);
319 assert_eq!(base.spacing.m, Some(12.0)); assert_eq!(base.spacing.s, Some(6.0)); }
322
323 #[test]
324 fn merge_icon_sizes_nested_merges_recursively() {
325 let mut base = ThemeDefaults {
326 icon_sizes: IconSizes {
327 toolbar: Some(22.0),
328 ..Default::default()
329 },
330 ..Default::default()
331 };
332 let overlay = ThemeDefaults {
333 icon_sizes: IconSizes {
334 small: Some(16.0),
335 ..Default::default()
336 },
337 ..Default::default()
338 };
339 base.merge(&overlay);
340 assert_eq!(base.icon_sizes.toolbar, Some(22.0)); assert_eq!(base.icon_sizes.small, Some(16.0)); }
343
344 #[test]
347 fn toml_round_trip_accent_and_font_family() {
348 let d = ThemeDefaults {
349 accent: Some(Rgba::rgb(0, 120, 215)),
350 font: FontSpec {
351 family: Some("Inter".into()),
352 ..Default::default()
353 },
354 ..Default::default()
355 };
356 let toml_str = toml::to_string(&d).unwrap();
357 assert!(
359 toml_str.contains("[font]"),
360 "Expected [font] section, got: {toml_str}"
361 );
362 assert!(
364 toml_str.contains("accent"),
365 "Expected accent field, got: {toml_str}"
366 );
367 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
369 assert_eq!(d, d2);
370 }
371
372 #[test]
373 fn toml_empty_sections_suppressed() {
374 let d = ThemeDefaults::default();
376 let toml_str = toml::to_string(&d).unwrap();
377 assert!(
379 !toml_str.contains("[font]"),
380 "Empty font should be suppressed: {toml_str}"
381 );
382 assert!(
383 !toml_str.contains("[mono_font]"),
384 "Empty mono_font should be suppressed: {toml_str}"
385 );
386 assert!(
387 !toml_str.contains("[spacing]"),
388 "Empty spacing should be suppressed: {toml_str}"
389 );
390 assert!(
391 !toml_str.contains("[icon_sizes]"),
392 "Empty icon_sizes should be suppressed: {toml_str}"
393 );
394 }
395
396 #[test]
397 fn toml_mono_font_sub_table() {
398 let d = ThemeDefaults {
399 mono_font: FontSpec {
400 family: Some("JetBrains Mono".into()),
401 size: Some(12.0),
402 ..Default::default()
403 },
404 ..Default::default()
405 };
406 let toml_str = toml::to_string(&d).unwrap();
407 assert!(
408 toml_str.contains("[mono_font]"),
409 "Expected [mono_font] section, got: {toml_str}"
410 );
411 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
412 assert_eq!(d, d2);
413 }
414
415 #[test]
416 fn toml_spacing_sub_table() {
417 let d = ThemeDefaults {
418 spacing: ThemeSpacing {
419 m: Some(12.0),
420 l: Some(18.0),
421 ..Default::default()
422 },
423 ..Default::default()
424 };
425 let toml_str = toml::to_string(&d).unwrap();
426 assert!(
427 toml_str.contains("[spacing]"),
428 "Expected [spacing] section, got: {toml_str}"
429 );
430 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
431 assert_eq!(d, d2);
432 }
433
434 #[test]
435 fn accessibility_fields_round_trip() {
436 let d = ThemeDefaults {
437 text_scaling_factor: Some(1.25),
438 reduce_motion: Some(true),
439 high_contrast: Some(false),
440 reduce_transparency: Some(true),
441 ..Default::default()
442 };
443 let toml_str = toml::to_string(&d).unwrap();
444 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
445 assert_eq!(d, d2);
446 }
447}