1use crate::Rgba;
4use native_theme_derive::ThemeFields;
5use serde::{Deserialize, Serialize};
6
7#[serde_with::skip_serializing_none]
23#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
24#[serde(default)]
25pub struct DefaultsBorderSpec {
26 pub color: Option<Rgba>,
28 #[serde(rename = "corner_radius_px")]
30 pub corner_radius: Option<f32>,
31 #[serde(rename = "corner_radius_lg_px")]
33 pub corner_radius_lg: Option<f32>,
34 #[serde(rename = "line_width_px")]
36 pub line_width: Option<f32>,
37 pub opacity: Option<f32>,
46 pub shadow_enabled: Option<bool>,
48}
49
50impl_merge!(DefaultsBorderSpec {
51 option { color, corner_radius, corner_radius_lg, line_width, opacity, shadow_enabled }
52});
53
54#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
81#[serde(try_from = "WidgetBorderSpecRaw", into = "WidgetBorderSpecRaw")]
82#[theme_layer(
87 fields = "color, corner_radius_px, line_width_px, shadow_enabled, padding_top_px, padding_right_px, padding_bottom_px, padding_left_px, padding_horizontal_px, padding_vertical_px"
88)]
89pub struct WidgetBorderSpec {
90 pub color: Option<Rgba>,
92 pub corner_radius: Option<f32>,
94 pub line_width: Option<f32>,
96 pub shadow_enabled: Option<bool>,
98 pub padding_top: Option<f32>,
100 pub padding_right: Option<f32>,
102 pub padding_bottom: Option<f32>,
104 pub padding_left: Option<f32>,
106}
107
108#[serde_with::skip_serializing_none]
111#[derive(Default, Serialize, Deserialize)]
112#[serde(default)]
113struct WidgetBorderSpecRaw {
114 color: Option<Rgba>,
115 corner_radius_px: Option<f32>,
116 line_width_px: Option<f32>,
117 shadow_enabled: Option<bool>,
118 padding_top_px: Option<f32>,
119 padding_right_px: Option<f32>,
120 padding_bottom_px: Option<f32>,
121 padding_left_px: Option<f32>,
122 padding_horizontal_px: Option<f32>,
123 padding_vertical_px: Option<f32>,
124}
125
126fn expand_axis(
129 axis: (&str, Option<f32>),
130 first: (&str, Option<f32>),
131 second: (&str, Option<f32>),
132) -> Result<(Option<f32>, Option<f32>), String> {
133 let Some(v) = axis.1 else {
134 return Ok((first.1, second.1));
135 };
136 for side in [first, second] {
137 if side.1.is_some() {
138 return Err(format!(
139 "border: set `{}` or `{}`, not both",
140 axis.0, side.0
141 ));
142 }
143 }
144 Ok((Some(v), Some(v)))
145}
146
147impl TryFrom<WidgetBorderSpecRaw> for WidgetBorderSpec {
148 type Error = String;
149 fn try_from(raw: WidgetBorderSpecRaw) -> Result<Self, Self::Error> {
150 let (padding_left, padding_right) = expand_axis(
151 ("padding_horizontal_px", raw.padding_horizontal_px),
152 ("padding_left_px", raw.padding_left_px),
153 ("padding_right_px", raw.padding_right_px),
154 )?;
155 let (padding_top, padding_bottom) = expand_axis(
156 ("padding_vertical_px", raw.padding_vertical_px),
157 ("padding_top_px", raw.padding_top_px),
158 ("padding_bottom_px", raw.padding_bottom_px),
159 )?;
160 Ok(WidgetBorderSpec {
161 color: raw.color,
162 corner_radius: raw.corner_radius_px,
163 line_width: raw.line_width_px,
164 shadow_enabled: raw.shadow_enabled,
165 padding_top,
166 padding_right,
167 padding_bottom,
168 padding_left,
169 })
170 }
171}
172
173impl From<WidgetBorderSpec> for WidgetBorderSpecRaw {
174 fn from(b: WidgetBorderSpec) -> Self {
175 WidgetBorderSpecRaw {
176 color: b.color,
177 corner_radius_px: b.corner_radius,
178 line_width_px: b.line_width,
179 shadow_enabled: b.shadow_enabled,
180 padding_top_px: b.padding_top,
181 padding_right_px: b.padding_right,
182 padding_bottom_px: b.padding_bottom,
183 padding_left_px: b.padding_left,
184 padding_horizontal_px: None,
185 padding_vertical_px: None,
186 }
187 }
188}
189
190impl_merge!(WidgetBorderSpec {
191 option {
192 color, corner_radius, line_width, shadow_enabled,
193 padding_top, padding_right, padding_bottom, padding_left
194 }
195});
196
197#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
203pub struct ResolvedPadding {
204 pub top: Option<f32>,
206 pub right: Option<f32>,
208 pub bottom: Option<f32>,
210 pub left: Option<f32>,
212}
213
214#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
220pub struct ResolvedDefaultsBorder {
221 pub color: Rgba,
223 pub corner_radius: f32,
225 pub corner_radius_lg: f32,
227 pub line_width: f32,
229 pub opacity: f32,
233 pub shadow_enabled: bool,
235}
236
237#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243pub struct ResolvedWidgetBorder {
244 pub color: Rgba,
246 pub corner_radius: f32,
248 pub line_width: f32,
250 pub shadow_enabled: bool,
252 #[serde(default)]
255 pub padding: ResolvedPadding,
256}
257
258#[cfg(test)]
259#[allow(clippy::unwrap_used, clippy::expect_used)]
260mod tests {
261 use super::*;
262
263 #[test]
266 fn defaults_border_spec_default_is_empty() {
267 assert!(DefaultsBorderSpec::default().is_empty());
268 }
269
270 #[test]
271 fn defaults_border_spec_not_empty_when_color_set() {
272 let bs = DefaultsBorderSpec {
273 color: Some(Rgba::rgb(100, 100, 100)),
274 ..Default::default()
275 };
276 assert!(!bs.is_empty());
277 }
278
279 #[test]
280 fn defaults_border_spec_toml_round_trip_full() {
281 let bs = DefaultsBorderSpec {
282 color: Some(Rgba::rgb(200, 200, 200)),
283 corner_radius: Some(4.0),
284 corner_radius_lg: Some(8.0),
285 line_width: Some(1.0),
286 opacity: Some(0.15),
287 shadow_enabled: Some(true),
288 };
289 let toml_str = toml::to_string(&bs).unwrap();
290 let deserialized: DefaultsBorderSpec = toml::from_str(&toml_str).unwrap();
291 assert_eq!(deserialized, bs);
292 }
293
294 #[test]
295 fn defaults_border_spec_toml_round_trip_partial() {
296 let bs = DefaultsBorderSpec {
297 color: Some(Rgba::rgb(100, 100, 100)),
298 corner_radius: Some(8.0),
299 corner_radius_lg: None,
300 line_width: None,
301 opacity: None,
302 shadow_enabled: None,
303 };
304 let toml_str = toml::to_string(&bs).unwrap();
305 let deserialized: DefaultsBorderSpec = toml::from_str(&toml_str).unwrap();
306 assert_eq!(deserialized, bs);
307 assert!(deserialized.corner_radius_lg.is_none());
308 assert!(deserialized.line_width.is_none());
309 assert!(deserialized.opacity.is_none());
310 assert!(deserialized.shadow_enabled.is_none());
311 }
312
313 #[test]
314 fn defaults_border_spec_merge_overlay_wins() {
315 let mut base = DefaultsBorderSpec {
316 color: Some(Rgba::rgb(100, 100, 100)),
317 corner_radius: Some(4.0),
318 ..Default::default()
319 };
320 let overlay = DefaultsBorderSpec {
321 color: Some(Rgba::rgb(200, 200, 200)),
322 ..Default::default()
323 };
324 base.merge(&overlay);
325 assert_eq!(base.color, Some(Rgba::rgb(200, 200, 200)));
326 assert_eq!(base.corner_radius, Some(4.0));
328 }
329
330 #[test]
333 fn widget_border_spec_default_is_empty() {
334 assert!(WidgetBorderSpec::default().is_empty());
335 }
336
337 #[test]
338 fn widget_border_spec_not_empty_when_color_set() {
339 let bs = WidgetBorderSpec {
340 color: Some(Rgba::rgb(100, 100, 100)),
341 ..Default::default()
342 };
343 assert!(!bs.is_empty());
344 }
345
346 #[test]
347 fn widget_border_spec_toml_round_trip_full() {
348 let bs = WidgetBorderSpec {
349 color: Some(Rgba::rgb(200, 200, 200)),
350 corner_radius: Some(4.0),
351 line_width: Some(1.0),
352 shadow_enabled: Some(true),
353 padding_top: Some(5.0),
354 padding_right: Some(6.0),
355 padding_bottom: Some(7.0),
356 padding_left: Some(8.0),
357 };
358 let toml_str = toml::to_string(&bs).unwrap();
359 let deserialized: WidgetBorderSpec = toml::from_str(&toml_str).unwrap();
360 assert_eq!(deserialized, bs);
361 }
362
363 #[test]
364 fn widget_border_spec_toml_round_trip_partial() {
365 let bs = WidgetBorderSpec {
366 color: Some(Rgba::rgb(100, 100, 100)),
367 corner_radius: Some(8.0),
368 line_width: None,
369 shadow_enabled: None,
370 padding_top: None,
371 padding_right: None,
372 padding_bottom: None,
373 padding_left: None,
374 };
375 let toml_str = toml::to_string(&bs).unwrap();
376 let deserialized: WidgetBorderSpec = toml::from_str(&toml_str).unwrap();
377 assert_eq!(deserialized, bs);
378 assert!(deserialized.line_width.is_none());
379 assert!(deserialized.shadow_enabled.is_none());
380 assert!(deserialized.padding_top.is_none());
381 assert!(deserialized.padding_right.is_none());
382 assert!(deserialized.padding_bottom.is_none());
383 assert!(deserialized.padding_left.is_none());
384 }
385
386 #[test]
387 fn widget_border_spec_merge_overlay_wins() {
388 let mut base = WidgetBorderSpec {
389 color: Some(Rgba::rgb(100, 100, 100)),
390 corner_radius: Some(4.0),
391 ..Default::default()
392 };
393 let overlay = WidgetBorderSpec {
394 color: Some(Rgba::rgb(200, 200, 200)),
395 ..Default::default()
396 };
397 base.merge(&overlay);
398 assert_eq!(base.color, Some(Rgba::rgb(200, 200, 200)));
399 assert_eq!(base.corner_radius, Some(4.0));
401 }
402
403 #[test]
406 fn padding_horizontal_shorthand_sets_left_and_right_only() {
407 let bs: WidgetBorderSpec = toml::from_str("padding_horizontal_px = 10").unwrap();
408 assert_eq!(bs.padding_left, Some(10.0));
409 assert_eq!(bs.padding_right, Some(10.0));
410 assert_eq!(bs.padding_top, None);
411 assert_eq!(bs.padding_bottom, None);
412 }
413
414 #[test]
415 fn padding_vertical_shorthand_states_a_zero() {
416 let bs: WidgetBorderSpec = toml::from_str("padding_vertical_px = 0.0").unwrap();
417 assert_eq!(bs.padding_top, Some(0.0));
418 assert_eq!(bs.padding_bottom, Some(0.0));
419 assert_eq!(bs.padding_left, None);
420 assert_eq!(bs.padding_right, None);
421 }
422
423 #[test]
424 fn an_axis_key_with_one_of_its_sides_is_rejected_naming_the_table_and_both_keys() {
425 let src = "[light.button.border]\npadding_horizontal_px = 10\npadding_left_px = 4\n";
426 let err = toml::from_str::<toml::Table>(src)
427 .ok()
428 .and_then(|t| t.get("light").cloned())
429 .and_then(|l| l.get("button").cloned())
430 .and_then(|b| b.get("border").cloned())
431 .map(|border| border.try_into::<WidgetBorderSpec>())
432 .expect("the table parses as TOML")
433 .expect_err("an axis key with one of its sides must be rejected")
434 .to_string();
435 assert!(err.contains("padding_horizontal_px"), "{err}");
436 assert!(err.contains("padding_left_px"), "{err}");
437
438 let theme = format!("name = \"T\"\n{src}");
440 let err = crate::Theme::from_toml(&theme)
441 .expect_err("the theme must not load")
442 .to_string();
443 assert!(err.contains("padding_horizontal_px"), "{err}");
444 assert!(err.contains("padding_left_px"), "{err}");
445 assert!(err.contains("light.button.border"), "{err}");
446 }
447
448 #[test]
449 fn the_vertical_axis_key_with_a_side_is_rejected_too() {
450 let err =
451 toml::from_str::<WidgetBorderSpec>("padding_vertical_px = 2\npadding_bottom_px = 3")
452 .expect_err("rejected")
453 .to_string();
454 assert!(err.contains("padding_vertical_px"), "{err}");
455 assert!(err.contains("padding_bottom_px"), "{err}");
456 }
457
458 #[test]
459 fn serialisation_writes_sides_never_the_shorthand() {
460 let bs: WidgetBorderSpec =
461 toml::from_str("padding_horizontal_px = 10\npadding_top_px = 3").unwrap();
462 let out = toml::to_string(&bs).unwrap();
463 assert!(out.contains("padding_left_px = 10"), "{out}");
464 assert!(out.contains("padding_right_px = 10"), "{out}");
465 assert!(out.contains("padding_top_px = 3"), "{out}");
466 assert!(!out.contains("padding_horizontal_px"), "{out}");
467 assert!(!out.contains("padding_bottom_px"), "{out}");
468 let back: WidgetBorderSpec = toml::from_str(&out).unwrap();
469 assert_eq!(back, bs);
470 }
471
472 #[test]
473 fn a_readers_left_side_over_a_presets_shorthand_wins_for_left_only() {
474 let mut preset: WidgetBorderSpec = toml::from_str("padding_horizontal_px = 10").unwrap();
475 let reader = WidgetBorderSpec {
476 padding_left: Some(4.0),
477 ..Default::default()
478 };
479 preset.merge(&reader);
480 assert_eq!(preset.padding_left, Some(4.0));
481 assert_eq!(preset.padding_right, Some(10.0));
482 assert_eq!(preset.padding_top, None);
483 assert_eq!(preset.padding_bottom, None);
484 }
485
486 #[test]
489 fn resolved_border_struct_literals_compile() {
490 let defaults = ResolvedDefaultsBorder {
492 color: Rgba::new(0, 0, 0, 0),
493 corner_radius: 0.0,
494 corner_radius_lg: 0.0,
495 line_width: 0.0,
496 opacity: 0.0,
497 shadow_enabled: false,
498 };
499 assert_eq!(defaults.corner_radius_lg, 0.0);
500 let widget = ResolvedWidgetBorder {
501 color: Rgba::new(0, 0, 0, 0),
502 corner_radius: 0.0,
503 line_width: 0.0,
504 shadow_enabled: false,
505 padding: ResolvedPadding {
506 top: None,
507 right: Some(0.0),
508 bottom: None,
509 left: Some(4.0),
510 },
511 };
512 assert_eq!(widget.padding.left, Some(4.0));
513 assert_eq!(widget.padding.top, None);
514 assert_eq!(ResolvedPadding::default().right, None);
515 }
516
517 #[test]
520 fn resolved_widget_border_without_padding_deserialises_unstated() {
521 let src =
522 "color = \"#000000\"\ncorner_radius = 2.0\nline_width = 1.0\nshadow_enabled = false\n";
523 let border: ResolvedWidgetBorder = toml::from_str(src).unwrap();
524 assert_eq!(border.padding, ResolvedPadding::default());
525 }
526}