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_merge!(ThemeDefaults {
137 option {
138 line_height,
139 background, foreground, accent, accent_foreground,
140 surface, border, muted, shadow, link, selection, selection_foreground,
141 selection_inactive, disabled_foreground,
142 danger, danger_foreground, warning, warning_foreground,
143 success, success_foreground, info, info_foreground,
144 radius, radius_lg, frame_width, disabled_opacity, border_opacity,
145 shadow_enabled, focus_ring_color, focus_ring_width, focus_ring_offset,
146 text_scaling_factor, reduce_motion, high_contrast, reduce_transparency
147 }
148 nested { font, mono_font, spacing, icon_sizes }
149});
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used)]
153mod tests {
154 use super::*;
155 use crate::Rgba;
156 use crate::model::spacing::ThemeSpacing;
157 use crate::model::{FontSpec, IconSizes};
158
159 #[test]
162 fn default_has_all_none_options() {
163 let d = ThemeDefaults::default();
164 assert!(d.background.is_none());
165 assert!(d.foreground.is_none());
166 assert!(d.accent.is_none());
167 assert!(d.accent_foreground.is_none());
168 assert!(d.surface.is_none());
169 assert!(d.border.is_none());
170 assert!(d.muted.is_none());
171 assert!(d.shadow.is_none());
172 assert!(d.link.is_none());
173 assert!(d.selection.is_none());
174 assert!(d.selection_foreground.is_none());
175 assert!(d.selection_inactive.is_none());
176 assert!(d.disabled_foreground.is_none());
177 assert!(d.danger.is_none());
178 assert!(d.danger_foreground.is_none());
179 assert!(d.warning.is_none());
180 assert!(d.warning_foreground.is_none());
181 assert!(d.success.is_none());
182 assert!(d.success_foreground.is_none());
183 assert!(d.info.is_none());
184 assert!(d.info_foreground.is_none());
185 assert!(d.radius.is_none());
186 assert!(d.radius_lg.is_none());
187 assert!(d.frame_width.is_none());
188 assert!(d.disabled_opacity.is_none());
189 assert!(d.border_opacity.is_none());
190 assert!(d.shadow_enabled.is_none());
191 assert!(d.focus_ring_color.is_none());
192 assert!(d.focus_ring_width.is_none());
193 assert!(d.focus_ring_offset.is_none());
194 assert!(d.text_scaling_factor.is_none());
195 assert!(d.reduce_motion.is_none());
196 assert!(d.high_contrast.is_none());
197 assert!(d.reduce_transparency.is_none());
198 assert!(d.line_height.is_none());
199 }
200
201 #[test]
202 fn default_nested_structs_are_all_empty() {
203 let d = ThemeDefaults::default();
204 assert!(d.font.is_empty());
205 assert!(d.mono_font.is_empty());
206 assert!(d.spacing.is_empty());
207 assert!(d.icon_sizes.is_empty());
208 }
209
210 #[test]
211 fn default_is_empty() {
212 assert!(ThemeDefaults::default().is_empty());
213 }
214
215 #[test]
216 fn not_empty_when_accent_set() {
217 let d = ThemeDefaults {
218 accent: Some(Rgba::rgb(0, 120, 215)),
219 ..Default::default()
220 };
221 assert!(!d.is_empty());
222 }
223
224 #[test]
225 fn not_empty_when_font_family_set() {
226 let d = ThemeDefaults {
227 font: FontSpec {
228 family: Some("Inter".into()),
229 ..Default::default()
230 },
231 ..Default::default()
232 };
233 assert!(!d.is_empty());
234 }
235
236 #[test]
237 fn not_empty_when_spacing_set() {
238 let d = ThemeDefaults {
239 spacing: ThemeSpacing {
240 m: Some(12.0),
241 ..Default::default()
242 },
243 ..Default::default()
244 };
245 assert!(!d.is_empty());
246 }
247
248 #[test]
251 fn font_is_plain_fontspec_not_option() {
252 let d = ThemeDefaults::default();
253 let _ = d.font.family;
255 let _ = d.font.size;
256 let _ = d.font.weight;
257 }
258
259 #[test]
260 fn mono_font_is_plain_fontspec_not_option() {
261 let d = ThemeDefaults::default();
262 let _ = d.mono_font.family;
263 }
264
265 #[test]
268 fn merge_option_overlay_wins() {
269 let mut base = ThemeDefaults {
270 accent: Some(Rgba::rgb(100, 100, 100)),
271 ..Default::default()
272 };
273 let overlay = ThemeDefaults {
274 accent: Some(Rgba::rgb(0, 120, 215)),
275 ..Default::default()
276 };
277 base.merge(&overlay);
278 assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
279 }
280
281 #[test]
282 fn merge_none_preserves_base() {
283 let mut base = ThemeDefaults {
284 accent: Some(Rgba::rgb(0, 120, 215)),
285 ..Default::default()
286 };
287 let overlay = ThemeDefaults::default();
288 base.merge(&overlay);
289 assert_eq!(base.accent, Some(Rgba::rgb(0, 120, 215)));
290 }
291
292 #[test]
293 fn merge_font_family_preserved_when_overlay_family_none() {
294 let mut base = ThemeDefaults {
295 font: FontSpec {
296 family: Some("Noto Sans".into()),
297 size: Some(11.0),
298 weight: None,
299 },
300 ..Default::default()
301 };
302 let overlay = ThemeDefaults {
303 font: FontSpec {
304 family: None,
305 size: None,
306 weight: Some(700),
307 },
308 ..Default::default()
309 };
310 base.merge(&overlay);
311 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)); }
315
316 #[test]
317 fn merge_spacing_nested_merges_recursively() {
318 let mut base = ThemeDefaults {
319 spacing: ThemeSpacing {
320 m: Some(12.0),
321 ..Default::default()
322 },
323 ..Default::default()
324 };
325 let overlay = ThemeDefaults {
326 spacing: ThemeSpacing {
327 s: Some(6.0),
328 ..Default::default()
329 },
330 ..Default::default()
331 };
332 base.merge(&overlay);
333 assert_eq!(base.spacing.m, Some(12.0)); assert_eq!(base.spacing.s, Some(6.0)); }
336
337 #[test]
338 fn merge_icon_sizes_nested_merges_recursively() {
339 let mut base = ThemeDefaults {
340 icon_sizes: IconSizes {
341 toolbar: Some(22.0),
342 ..Default::default()
343 },
344 ..Default::default()
345 };
346 let overlay = ThemeDefaults {
347 icon_sizes: IconSizes {
348 small: Some(16.0),
349 ..Default::default()
350 },
351 ..Default::default()
352 };
353 base.merge(&overlay);
354 assert_eq!(base.icon_sizes.toolbar, Some(22.0)); assert_eq!(base.icon_sizes.small, Some(16.0)); }
357
358 #[test]
361 fn toml_round_trip_accent_and_font_family() {
362 let d = ThemeDefaults {
363 accent: Some(Rgba::rgb(0, 120, 215)),
364 font: FontSpec {
365 family: Some("Inter".into()),
366 ..Default::default()
367 },
368 ..Default::default()
369 };
370 let toml_str = toml::to_string(&d).unwrap();
371 assert!(
373 toml_str.contains("[font]"),
374 "Expected [font] section, got: {toml_str}"
375 );
376 assert!(
378 toml_str.contains("accent"),
379 "Expected accent field, got: {toml_str}"
380 );
381 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
383 assert_eq!(d, d2);
384 }
385
386 #[test]
387 fn toml_empty_sections_suppressed() {
388 let d = ThemeDefaults::default();
390 let toml_str = toml::to_string(&d).unwrap();
391 assert!(
393 !toml_str.contains("[font]"),
394 "Empty font should be suppressed: {toml_str}"
395 );
396 assert!(
397 !toml_str.contains("[mono_font]"),
398 "Empty mono_font should be suppressed: {toml_str}"
399 );
400 assert!(
401 !toml_str.contains("[spacing]"),
402 "Empty spacing should be suppressed: {toml_str}"
403 );
404 assert!(
405 !toml_str.contains("[icon_sizes]"),
406 "Empty icon_sizes should be suppressed: {toml_str}"
407 );
408 }
409
410 #[test]
411 fn toml_mono_font_sub_table() {
412 let d = ThemeDefaults {
413 mono_font: FontSpec {
414 family: Some("JetBrains Mono".into()),
415 size: Some(12.0),
416 ..Default::default()
417 },
418 ..Default::default()
419 };
420 let toml_str = toml::to_string(&d).unwrap();
421 assert!(
422 toml_str.contains("[mono_font]"),
423 "Expected [mono_font] section, got: {toml_str}"
424 );
425 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
426 assert_eq!(d, d2);
427 }
428
429 #[test]
430 fn toml_spacing_sub_table() {
431 let d = ThemeDefaults {
432 spacing: ThemeSpacing {
433 m: Some(12.0),
434 l: Some(18.0),
435 ..Default::default()
436 },
437 ..Default::default()
438 };
439 let toml_str = toml::to_string(&d).unwrap();
440 assert!(
441 toml_str.contains("[spacing]"),
442 "Expected [spacing] section, got: {toml_str}"
443 );
444 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
445 assert_eq!(d, d2);
446 }
447
448 #[test]
449 fn accessibility_fields_round_trip() {
450 let d = ThemeDefaults {
451 text_scaling_factor: Some(1.25),
452 reduce_motion: Some(true),
453 high_contrast: Some(false),
454 reduce_transparency: Some(true),
455 ..Default::default()
456 };
457 let toml_str = toml::to_string(&d).unwrap();
458 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
459 assert_eq!(d, d2);
460 }
461}