Skip to main content

native_theme/model/
border.rs

1// Border specification sub-structs for defaults-level and widget-level border properties
2
3use crate::Rgba;
4use native_theme_derive::ThemeFields;
5use serde::{Deserialize, Serialize};
6
7/// Defaults-level border specification: color, geometry, and the platform's
8/// line opacity.
9///
10/// Used on [`ThemeDefaults`](crate::model::ThemeDefaults) for global border
11/// properties that are inherited by per-widget borders.
12///
13/// **No padding fields:** Padding lives exclusively on [`WidgetBorderSpec`]
14/// because padding is a widget-level layout concern, not a global default.
15/// This split eliminated the former
16/// "derives-from-presence" rule where the resolver would fill padding with
17/// `0.0` based on whether `line_width` or `corner_radius` was set -- a
18/// confusing proxy heuristic that is no longer needed.
19///
20/// All fields are optional to support partial overlays -- a DefaultsBorderSpec
21/// with only `color` set will only override the color when merged.
22#[serde_with::skip_serializing_none]
23#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
24#[serde(default)]
25pub struct DefaultsBorderSpec {
26    /// Border color.
27    pub color: Option<Rgba>,
28    /// Corner radius in logical pixels.
29    #[serde(rename = "corner_radius_px")]
30    pub corner_radius: Option<f32>,
31    /// Large corner radius in logical pixels (defaults only).
32    #[serde(rename = "corner_radius_lg_px")]
33    pub corner_radius_lg: Option<f32>,
34    /// Border stroke width in logical pixels.
35    #[serde(rename = "line_width_px")]
36    pub line_width: Option<f32>,
37    /// The platform's line opacity, 0.0-1.0 (defaults only): the share of the
38    /// text colour that makes a border or separator line — KDE's
39    /// `frameContrast` 0.2, libadwaita's `--border-opacity` 0.15
40    /// (docs/platform-facts.md §2.1.6).
41    ///
42    /// It is already folded into every stated border colour, which is the
43    /// final line colour: no connector multiplies any colour by it. It
44    /// documents how the platform derives its lines.
45    pub opacity: Option<f32>,
46    /// Whether the bordered element has a drop shadow.
47    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/// Widget-level border specification: color, geometry, and padding.
55///
56/// Used on per-widget structs for border properties specific to individual
57/// widgets. Unlike [`DefaultsBorderSpec`], includes the four padding sides
58/// (widget-level layout) but omits `corner_radius_lg` and `opacity`
59/// (defaults-only geometry).
60///
61/// Padding fields are widget-only because different widgets need different
62/// internal padding even when sharing the same border geometry from defaults.
63///
64/// **Padding is per side.** A platform states padding side by side, and some
65/// sides differ (Windows' input is 10 left / 6 right, docs/platform-facts.md
66/// §2.4), so the model stores the four sides and nothing else. `None` means
67/// the platform states no value for that side; `Some(0.0)` means it states
68/// zero.
69///
70/// **TOML.** The sides are `padding_top_px`, `padding_right_px`,
71/// `padding_bottom_px` and `padding_left_px`. `padding_horizontal_px` and
72/// `padding_vertical_px` are parse-time shorthand that set both sides of
73/// their axis. A table that states an axis key together with one of that
74/// axis's sides is a parse error naming both keys. Serialisation writes
75/// sides only.
76///
77/// All fields are optional to support partial overlays, and they merge per
78/// field, the overlay winning, so a reader's left side over a preset's
79/// shorthand replaces the left side alone.
80#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ThemeFields)]
81#[serde(try_from = "WidgetBorderSpecRaw", into = "WidgetBorderSpecRaw")]
82// serde reads and writes this struct through WidgetBorderSpecRaw, which also
83// knows the two shorthand keys. The ThemeFields derive's introspection path
84// cannot see the proxy, so declare the wire-format field names explicitly
85// here, as FontSpec does. Keep in sync with WidgetBorderSpecRaw below.
86#[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    /// Border color.
91    pub color: Option<Rgba>,
92    /// Corner radius in logical pixels.
93    pub corner_radius: Option<f32>,
94    /// Border stroke width in logical pixels.
95    pub line_width: Option<f32>,
96    /// Whether the bordered element has a drop shadow.
97    pub shadow_enabled: Option<bool>,
98    /// Padding inside the border above the content, in logical pixels.
99    pub padding_top: Option<f32>,
100    /// Padding inside the border right of the content, in logical pixels.
101    pub padding_right: Option<f32>,
102    /// Padding inside the border below the content, in logical pixels.
103    pub padding_bottom: Option<f32>,
104    /// Padding inside the border left of the content, in logical pixels.
105    pub padding_left: Option<f32>,
106}
107
108/// Serde proxy for [`WidgetBorderSpec`]: the four side keys and the two axis
109/// shorthands.
110#[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
126/// Expand one axis shorthand into its two sides, rejecting a table that also
127/// states either side.
128fn 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/// A widget's resolved padding, one field per side.
198///
199/// `None` means the theme states no value for that side, so a connector
200/// leaves the toolkit's own padding in place there; `Some(0.0)` means the
201/// theme states zero.
202#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
203pub struct ResolvedPadding {
204    /// Padding above the content, in logical pixels.
205    pub top: Option<f32>,
206    /// Padding right of the content, in logical pixels.
207    pub right: Option<f32>,
208    /// Padding below the content, in logical pixels.
209    pub bottom: Option<f32>,
210    /// Padding left of the content, in logical pixels.
211    pub left: Option<f32>,
212}
213
214/// The resolved `defaults.border`: the global border geometry and colour
215/// that widget borders inherit.
216///
217/// No `Default` derive: It is constructed from a fully
218/// populated unresolved source.
219#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
220pub struct ResolvedDefaultsBorder {
221    /// Border color.
222    pub color: Rgba,
223    /// Corner radius in logical pixels.
224    pub corner_radius: f32,
225    /// Large corner radius in logical pixels.
226    pub corner_radius_lg: f32,
227    /// Border stroke width in logical pixels.
228    pub line_width: f32,
229    /// The platform's line opacity, 0.0-1.0: the share of the text colour
230    /// already folded into every stated border colour. No connector
231    /// multiplies any colour by it (see [`DefaultsBorderSpec::opacity`]).
232    pub opacity: f32,
233    /// Whether the bordered element has a drop shadow.
234    pub shadow_enabled: bool,
235}
236
237/// A widget's resolved border: its colour, geometry and padding.
238///
239/// No `Default` derive: Any "zero" instance is a
240/// placeholder sentinel built manually (see
241/// `resolve::validate_helpers::resolved_widget_border_sentinel`).
242#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243pub struct ResolvedWidgetBorder {
244    /// Border color.
245    pub color: Rgba,
246    /// Corner radius in logical pixels.
247    pub corner_radius: f32,
248    /// Border stroke width in logical pixels.
249    pub line_width: f32,
250    /// Whether the bordered element has a drop shadow.
251    pub shadow_enabled: bool,
252    /// Padding inside the border, per side; a side the theme does not state
253    /// is `None`.
254    #[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    // === DefaultsBorderSpec tests ===
264
265    #[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        // base corner_radius preserved since overlay corner_radius is None
327        assert_eq!(base.corner_radius, Some(4.0));
328    }
329
330    // === WidgetBorderSpec tests ===
331
332    #[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        // base corner_radius preserved since overlay corner_radius is None
400        assert_eq!(base.corner_radius, Some(4.0));
401    }
402
403    // === Per-side padding and its TOML shorthand ===
404
405    #[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        // Through a whole theme, where the error also names the table.
439        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    // === Resolved border tests ===
487
488    #[test]
489    fn resolved_border_struct_literals_compile() {
490        // Field-name compile guard: any rename/remove breaks these literals.
491        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    /// A resolved border serialised without its padding (by a version that
518    /// had none, or by hand) deserialises with every side unstated.
519    #[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}