1use std::borrow::Cow;
4
5use crate::Rgba;
6use crate::model::border::DefaultsBorderSpec;
7use crate::model::{FontSpec, IconSizes};
8use native_theme_derive::ThemeFields;
9use serde::{Deserialize, Serialize};
10
11#[serde_with::skip_serializing_none]
35#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
36#[serde(default)]
37pub struct ThemeDefaults {
38 #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
41 pub font: FontSpec,
42
43 pub line_height: Option<f32>,
45
46 #[serde(default, skip_serializing_if = "FontSpec::is_empty")]
48 pub mono_font: FontSpec,
49
50 pub background_color: Option<Rgba>,
53 pub text_color: Option<Rgba>,
55 pub accent_color: Option<Rgba>,
57 pub accent_text_color: Option<Rgba>,
59 pub surface_color: Option<Rgba>,
61 pub muted_color: Option<Rgba>,
63 pub shadow_color: Option<Rgba>,
65 pub link_color: Option<Rgba>,
67 pub selection_background: Option<Rgba>,
69 pub selection_text_color: Option<Rgba>,
71 pub selection_inactive_background: Option<Rgba>,
73 pub text_selection_background: Option<Rgba>,
75 pub text_selection_color: Option<Rgba>,
77 pub disabled_text_color: Option<Rgba>,
80
81 pub danger_color: Option<Rgba>,
84 pub danger_text_color: Option<Rgba>,
86 pub warning_color: Option<Rgba>,
88 pub warning_text_color: Option<Rgba>,
90 pub success_color: Option<Rgba>,
92 pub success_text_color: Option<Rgba>,
94 pub info_color: Option<Rgba>,
96 pub info_text_color: Option<Rgba>,
98
99 #[serde(default, skip_serializing_if = "DefaultsBorderSpec::is_empty")]
102 pub border: DefaultsBorderSpec,
103 pub disabled_opacity: Option<f32>,
113
114 pub focus_ring_color: Option<Rgba>,
117 #[serde(rename = "focus_ring_width_px")]
119 pub focus_ring_width: Option<f32>,
120 #[serde(rename = "focus_ring_offset_px")]
122 pub focus_ring_offset: Option<f32>,
123
124 #[serde(default, skip_serializing_if = "IconSizes::is_empty")]
127 pub icon_sizes: IconSizes,
128
129 pub icon_theme: Option<Cow<'static, str>>,
146}
147
148impl_merge!(ThemeDefaults {
154 option {
155 line_height,
156 background_color, text_color, accent_color, accent_text_color,
157 surface_color, muted_color, shadow_color, link_color,
158 selection_background, selection_text_color,
159 selection_inactive_background,
160 text_selection_background, text_selection_color,
161 disabled_text_color,
162 danger_color, danger_text_color, warning_color, warning_text_color,
163 success_color, success_text_color, info_color, info_text_color,
164 disabled_opacity, focus_ring_color, focus_ring_width, focus_ring_offset,
165 icon_theme
166 }
167 nested { font, mono_font, border, icon_sizes }
168});
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used, clippy::expect_used)]
172mod tests {
173 use super::*;
174 use crate::Rgba;
175 use crate::model::border::DefaultsBorderSpec;
176 use crate::model::font::FontSize;
177 use crate::model::{FontSpec, IconSizes};
178
179 #[test]
182 fn default_has_all_none_options() {
183 let d = ThemeDefaults::default();
184 assert!(d.background_color.is_none());
185 assert!(d.text_color.is_none());
186 assert!(d.accent_color.is_none());
187 assert!(d.accent_text_color.is_none());
188 assert!(d.surface_color.is_none());
189 assert!(d.muted_color.is_none());
190 assert!(d.shadow_color.is_none());
191 assert!(d.link_color.is_none());
192 assert!(d.selection_background.is_none());
193 assert!(d.selection_text_color.is_none());
194 assert!(d.selection_inactive_background.is_none());
195 assert!(d.text_selection_background.is_none());
196 assert!(d.text_selection_color.is_none());
197 assert!(d.disabled_text_color.is_none());
198 assert!(d.danger_color.is_none());
199 assert!(d.danger_text_color.is_none());
200 assert!(d.warning_color.is_none());
201 assert!(d.warning_text_color.is_none());
202 assert!(d.success_color.is_none());
203 assert!(d.success_text_color.is_none());
204 assert!(d.info_color.is_none());
205 assert!(d.info_text_color.is_none());
206 assert!(d.disabled_opacity.is_none());
207 assert!(d.focus_ring_color.is_none());
208 assert!(d.focus_ring_width.is_none());
209 assert!(d.focus_ring_offset.is_none());
210 assert!(d.line_height.is_none());
211 assert!(d.icon_theme.is_none());
212 }
213
214 #[test]
215 fn default_nested_structs_are_all_empty() {
216 let d = ThemeDefaults::default();
217 assert!(d.font.is_empty());
218 assert!(d.mono_font.is_empty());
219 assert!(d.border.is_empty());
220 assert!(d.icon_sizes.is_empty());
221 }
222
223 #[test]
224 fn default_is_empty() {
225 assert!(ThemeDefaults::default().is_empty());
226 }
227
228 #[test]
229 fn not_empty_when_accent_color_set() {
230 let d = ThemeDefaults {
231 accent_color: Some(Rgba::rgb(0, 120, 215)),
232 ..Default::default()
233 };
234 assert!(!d.is_empty());
235 }
236
237 #[test]
238 fn not_empty_when_font_family_set() {
239 let d = ThemeDefaults {
240 font: FontSpec {
241 family: Some("Inter".into()),
242 ..Default::default()
243 },
244 ..Default::default()
245 };
246 assert!(!d.is_empty());
247 }
248
249 #[test]
250 fn not_empty_when_border_set() {
251 let d = ThemeDefaults {
252 border: DefaultsBorderSpec {
253 corner_radius: Some(4.0),
254 ..Default::default()
255 },
256 ..Default::default()
257 };
258 assert!(!d.is_empty());
259 }
260
261 #[test]
264 fn font_is_plain_fontspec_not_option() {
265 let d = ThemeDefaults::default();
266 let _ = d.font.family;
268 let _ = d.font.size;
269 let _ = d.font.weight;
270 }
271
272 #[test]
273 fn mono_font_is_plain_fontspec_not_option() {
274 let d = ThemeDefaults::default();
275 let _ = d.mono_font.family;
276 }
277
278 #[test]
281 fn merge_option_overlay_wins() {
282 let mut base = ThemeDefaults {
283 accent_color: Some(Rgba::rgb(100, 100, 100)),
284 ..Default::default()
285 };
286 let overlay = ThemeDefaults {
287 accent_color: Some(Rgba::rgb(0, 120, 215)),
288 ..Default::default()
289 };
290 base.merge(&overlay);
291 assert_eq!(base.accent_color, Some(Rgba::rgb(0, 120, 215)));
292 }
293
294 #[test]
295 fn merge_none_preserves_base() {
296 let mut base = ThemeDefaults {
297 accent_color: Some(Rgba::rgb(0, 120, 215)),
298 ..Default::default()
299 };
300 let overlay = ThemeDefaults::default();
301 base.merge(&overlay);
302 assert_eq!(base.accent_color, Some(Rgba::rgb(0, 120, 215)));
303 }
304
305 #[test]
306 fn merge_font_family_preserved_when_overlay_family_none() {
307 let mut base = ThemeDefaults {
308 font: FontSpec {
309 family: Some("Noto Sans".into()),
310 size: Some(FontSize::Px(11.0)),
311 weight: None,
312 ..Default::default()
313 },
314 ..Default::default()
315 };
316 let overlay = ThemeDefaults {
317 font: FontSpec {
318 family: None,
319 size: None,
320 weight: Some(700),
321 ..Default::default()
322 },
323 ..Default::default()
324 };
325 base.merge(&overlay);
326 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)); }
330
331 #[test]
332 fn merge_border_nested_merges_recursively() {
333 let mut base = ThemeDefaults {
334 border: DefaultsBorderSpec {
335 corner_radius: Some(4.0),
336 ..Default::default()
337 },
338 ..Default::default()
339 };
340 let overlay = ThemeDefaults {
341 border: DefaultsBorderSpec {
342 line_width: Some(1.0),
343 ..Default::default()
344 },
345 ..Default::default()
346 };
347 base.merge(&overlay);
348 assert_eq!(base.border.corner_radius, Some(4.0)); assert_eq!(base.border.line_width, Some(1.0)); }
351
352 #[test]
353 fn merge_icon_sizes_nested_merges_recursively() {
354 let mut base = ThemeDefaults {
355 icon_sizes: IconSizes {
356 toolbar: Some(22.0),
357 ..Default::default()
358 },
359 ..Default::default()
360 };
361 let overlay = ThemeDefaults {
362 icon_sizes: IconSizes {
363 small: Some(16.0),
364 ..Default::default()
365 },
366 ..Default::default()
367 };
368 base.merge(&overlay);
369 assert_eq!(base.icon_sizes.toolbar, Some(22.0)); assert_eq!(base.icon_sizes.small, Some(16.0)); }
372
373 #[test]
376 fn toml_round_trip_accent_color_and_font_family() {
377 let d = ThemeDefaults {
378 accent_color: Some(Rgba::rgb(0, 120, 215)),
379 font: FontSpec {
380 family: Some("Inter".into()),
381 ..Default::default()
382 },
383 ..Default::default()
384 };
385 let toml_str = toml::to_string(&d).unwrap();
386 assert!(
388 toml_str.contains("[font]"),
389 "Expected [font] section, got: {toml_str}"
390 );
391 assert!(
393 toml_str.contains("accent_color"),
394 "Expected accent_color field, got: {toml_str}"
395 );
396 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
398 assert_eq!(d, d2);
399 }
400
401 #[test]
402 fn toml_empty_sections_suppressed() {
403 let d = ThemeDefaults::default();
405 let toml_str = toml::to_string(&d).unwrap();
406 assert!(
408 !toml_str.contains("[font]"),
409 "Empty font should be suppressed: {toml_str}"
410 );
411 assert!(
412 !toml_str.contains("[mono_font]"),
413 "Empty mono_font should be suppressed: {toml_str}"
414 );
415 assert!(
416 !toml_str.contains("[border]"),
417 "Empty border should be suppressed: {toml_str}"
418 );
419 assert!(
420 !toml_str.contains("[icon_sizes]"),
421 "Empty icon_sizes should be suppressed: {toml_str}"
422 );
423 }
424
425 #[test]
426 fn toml_mono_font_sub_table() {
427 let d = ThemeDefaults {
428 mono_font: FontSpec {
429 family: Some("JetBrains Mono".into()),
430 size: Some(FontSize::Px(12.0)),
431 ..Default::default()
432 },
433 ..Default::default()
434 };
435 let toml_str = toml::to_string(&d).unwrap();
436 assert!(
437 toml_str.contains("[mono_font]"),
438 "Expected [mono_font] section, got: {toml_str}"
439 );
440 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
441 assert_eq!(d, d2);
442 }
443
444 #[test]
445 fn toml_border_sub_table() {
446 let d = ThemeDefaults {
447 border: DefaultsBorderSpec {
448 corner_radius: Some(4.0),
449 line_width: Some(1.0),
450 ..Default::default()
451 },
452 ..Default::default()
453 };
454 let toml_str = toml::to_string(&d).unwrap();
455 assert!(
456 toml_str.contains("[border]"),
457 "Expected [border] section, got: {toml_str}"
458 );
459 let d2: ThemeDefaults = toml::from_str(&toml_str).unwrap();
460 assert_eq!(d, d2);
461 }
462}