Skip to main content

native_theme/model/widgets/
mod.rs

1// Per-widget struct pairs: all 26 per-variant widgets + LayoutTheme use #[derive(ThemeWidget)].
2
3use crate::Rgba;
4use crate::model::border::{ResolvedWidgetBorder, WidgetBorderSpec};
5use crate::model::{ArrowSide, DialogButtonOrder, FontSpec, ResolvedFontSpec, TabIndicatorSide};
6use native_theme_derive::{ThemeFields, ThemeWidget};
7
8// ── 2.2 Window / Application Chrome ────────────────────────────────────────
9
10/// Window chrome: background, title bar colors, inactive states, geometry.
11#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
12#[serde_with::skip_serializing_none]
13#[serde(default)]
14#[theme_inherit(border_kind = "full_lg", font = "title_bar_font")]
15pub struct WindowTheme {
16    /// Main window background fill.
17    #[theme(inherit_from = "defaults.background_color")]
18    pub background_color: Option<Rgba>,
19    /// Active title bar background fill.
20    #[theme(inherit_from = "defaults.surface_color")]
21    pub title_bar_background: Option<Rgba>,
22    /// Title bar background when the window is unfocused.
23    pub inactive_title_bar_background: Option<Rgba>,
24    /// Title bar text color when the window is unfocused.
25    pub inactive_title_bar_text_color: Option<Rgba>,
26    /// Title bar font specification.
27    #[theme(nested, resolved_type = "ResolvedFontSpec")]
28    pub title_bar_font: Option<FontSpec>,
29    /// Window border specification.
30    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
31    pub border: Option<WidgetBorderSpec>,
32}
33
34// ── 2.3 Button ──────────────────────────────────────────────────────────────
35
36/// Push button: colors, sizing, spacing, geometry.
37#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
38#[serde_with::skip_serializing_none]
39#[serde(default)]
40#[theme_inherit(border_kind = "full", font = "font")]
41pub struct ButtonTheme {
42    /// Default button background fill.
43    #[theme(inherit_from = "defaults.background_color")]
44    pub background_color: Option<Rgba>,
45    /// Primary / accent button background fill.
46    #[theme(inherit_from = "defaults.accent_color")]
47    pub primary_background: Option<Rgba>,
48    /// Primary / accent button text/icon color.
49    #[theme(inherit_from = "defaults.accent_text_color")]
50    pub primary_text_color: Option<Rgba>,
51    /// Minimum button width in logical pixels.
52    #[serde(rename = "min_width_px")]
53    #[theme(check = "non_negative")]
54    pub min_width: Option<f32>,
55    /// Minimum button height in logical pixels.
56    #[serde(rename = "min_height_px")]
57    #[theme(check = "non_negative")]
58    pub min_height: Option<f32>,
59    /// Space between icon and label.
60    #[serde(rename = "icon_text_gap_px")]
61    #[theme(check = "non_negative")]
62    pub icon_text_gap: Option<f32>,
63    /// Opacity multiplier when the button is disabled (0.0-1.0), applied to
64    /// the whole button on top of its disabled colours (see
65    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
66    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
67    pub disabled_opacity: Option<f32>,
68    /// Button background on hover.
69    #[theme(inherit_from = "defaults.background_color")]
70    pub hover_background: Option<Rgba>,
71    /// Button text color on hover.
72    pub hover_text_color: Option<Rgba>,
73    /// Button text color when pressed/active.
74    pub active_text_color: Option<Rgba>,
75    /// Button text color when disabled.
76    #[theme(inherit_from = "defaults.disabled_text_color")]
77    pub disabled_text_color: Option<Rgba>,
78    /// Button background when pressed/active.
79    #[theme(category = "soft_option")]
80    pub active_background: Option<Rgba>,
81    /// Button background when disabled; `None`, the enabled background.
82    #[theme(category = "soft_option")]
83    pub disabled_background: Option<Rgba>,
84    /// Background of a toggle button that is on (checked): Breeze's
85    /// `Button` mixed an eighth towards `ButtonText`, WinUI's accent fill,
86    /// libadwaita's pressed fill, Material's `secondary`. `None` where the
87    /// platform states none (AppKit); a connector then shows the pressed
88    /// pair, `active_background` and `active_text_color`
89    /// (docs/platform-facts.md §2.3).
90    #[theme(category = "soft_option")]
91    pub checked_background: Option<Rgba>,
92    /// Label color of a toggle button that is on; `None`, as for
93    /// `checked_background`, the pressed pair's (docs/platform-facts.md §2.3).
94    #[theme(category = "soft_option")]
95    pub checked_text_color: Option<Rgba>,
96    /// Button label font specification.
97    #[theme(nested, resolved_type = "ResolvedFontSpec")]
98    pub font: Option<FontSpec>,
99    /// Button border specification.
100    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
101    pub border: Option<WidgetBorderSpec>,
102}
103
104// ── 2.4 Text Input ──────────────────────────────────────────────────────────
105
106/// Single-line and multi-line text input fields: everything of a multi-line
107/// field but its padding, which is [`TextAreaTheme`]'s.
108#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
109#[serde_with::skip_serializing_none]
110#[serde(default)]
111#[theme_inherit(border_kind = "full", font = "font")]
112pub struct InputTheme {
113    /// Input field background fill.
114    #[theme(inherit_from = "defaults.background_color")]
115    pub background_color: Option<Rgba>,
116    /// Placeholder text color.
117    #[theme(inherit_from = "defaults.muted_color")]
118    pub placeholder_color: Option<Rgba>,
119    /// Text cursor (caret) color.
120    pub caret_color: Option<Rgba>,
121    /// Text selection highlight color.
122    #[theme(inherit_from = "defaults.text_selection_background")]
123    pub selection_background: Option<Rgba>,
124    /// Text color inside the selection highlight.
125    #[theme(inherit_from = "defaults.text_selection_color")]
126    pub selection_text_color: Option<Rgba>,
127    /// Minimum field height in logical pixels.
128    #[serde(rename = "min_height_px")]
129    #[theme(check = "non_negative")]
130    pub min_height: Option<f32>,
131    /// Opacity multiplier when disabled (0.0-1.0), applied to the whole
132    /// widget on top of its disabled colours (see
133    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
134    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
135    pub disabled_opacity: Option<f32>,
136    /// Input text color when disabled.
137    #[theme(inherit_from = "defaults.disabled_text_color")]
138    pub disabled_text_color: Option<Rgba>,
139    /// Border color when the input is hovered.
140    #[theme(category = "soft_option")]
141    pub hover_border_color: Option<Rgba>,
142    /// Border color when the input has focus.
143    #[theme(category = "soft_option")]
144    pub focus_border_color: Option<Rgba>,
145    /// Input background when disabled; `None`, the enabled background.
146    #[theme(category = "soft_option")]
147    pub disabled_background: Option<Rgba>,
148    /// Input text font specification.
149    #[theme(nested, resolved_type = "ResolvedFontSpec")]
150    pub font: Option<FontSpec>,
151    /// Input border specification.
152    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
153    pub border: Option<WidgetBorderSpec>,
154}
155
156// ── 2.29 Text Area (multi-line) ──────────────────────────────────────────────
157
158/// Multi-line text field: the padding in which it differs from the
159/// single-line field. Everything else a text area shows -- its fill, text,
160/// placeholder, caret, selection, states and font -- is [`InputTheme`]'s.
161#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
162#[serde_with::skip_serializing_none]
163#[serde(default)]
164pub struct TextAreaTheme {
165    /// Text area border specification. Its padding sides are the multi-line
166    /// field's own, `None` where the platform states none
167    /// (docs/platform-facts.md §2.29); its colour, corner radius, line width
168    /// and shadow inherit `input.border`'s: the platforms frame both fields
169    /// alike.
170    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
171    pub border: Option<WidgetBorderSpec>,
172}
173
174// ── 2.5 Checkbox / Radio Button ────────────────────────────────────────────
175
176/// Checkbox and radio button theme: colors, indicator, label font, border, and interactive states.
177#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
178#[serde_with::skip_serializing_none]
179#[serde(default)]
180#[theme_inherit(border_kind = "full", font = "font")]
181pub struct CheckboxTheme {
182    /// Checkbox background color.
183    #[theme(inherit_from = "defaults.background_color")]
184    pub background_color: Option<Rgba>,
185    /// Indicator background when checked.
186    #[theme(inherit_from = "defaults.accent_color")]
187    pub checked_background: Option<Rgba>,
188    /// Indicator (check mark / radio dot) color.
189    #[theme(inherit_from = "defaults.accent_text_color")]
190    pub indicator_color: Option<Rgba>,
191    /// Side length of the checkbox indicator (square), or diameter of the
192    /// radio indicator (circle), in logical pixels: the whole indicator, not
193    /// the mark drawn inside it (docs/platform-facts.md:980).
194    #[serde(rename = "indicator_width_px")]
195    #[theme(check = "non_negative")]
196    pub indicator_width: Option<f32>,
197    /// Diameter of the radio indicator (circle) in logical pixels, where the
198    /// platform sizes it apart from the checkbox's square: Material's radio is
199    /// 20 across, its checkbox 18. `None` where the platform sizes both alike,
200    /// and the radio is `indicator_width` across (docs/platform-facts.md §2.5).
201    #[serde(rename = "radio_indicator_width_px")]
202    #[theme(category = "soft_option", check = "non_negative")]
203    pub radio_indicator_width: Option<f32>,
204    /// Diameter of the filled dot a selected radio button draws at the centre
205    /// of its indicator, in logical pixels. `None` where the platform states
206    /// no dot size: AppKit draws its own (docs/platform-facts.md §2.5).
207    #[serde(rename = "radio_dot_diameter_px")]
208    #[theme(category = "soft_option", check = "non_negative")]
209    pub radio_dot_diameter: Option<f32>,
210    /// Stroke width of the check mark a checked checkbox draws, in logical
211    /// pixels. `None` where the platform states none: WinUI's mark is a font
212    /// glyph and AppKit publishes none (docs/platform-facts.md §2.5).
213    #[serde(rename = "check_mark_stroke_width_px")]
214    #[theme(category = "soft_option", check = "non_negative")]
215    pub check_mark_stroke_width: Option<f32>,
216    /// Space between indicator and label.
217    #[serde(rename = "label_gap_px")]
218    #[theme(check = "non_negative")]
219    pub label_gap: Option<f32>,
220    /// Opacity multiplier when disabled (0.0-1.0), applied to the whole
221    /// widget on top of its disabled colours (see
222    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
223    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
224    pub disabled_opacity: Option<f32>,
225    /// Checkbox label text color when disabled.
226    #[theme(inherit_from = "defaults.disabled_text_color")]
227    pub disabled_text_color: Option<Rgba>,
228    /// Checkbox background on hover.
229    #[theme(category = "soft_option")]
230    pub hover_background: Option<Rgba>,
231    /// Checkbox background when disabled; `None`, the enabled box of its
232    /// state (checked or unchecked).
233    #[theme(category = "soft_option")]
234    pub disabled_background: Option<Rgba>,
235    /// Indicator background when unchecked.
236    #[theme(category = "soft_option")]
237    pub unchecked_background: Option<Rgba>,
238    /// Border color when unchecked.
239    #[theme(category = "soft_option")]
240    pub unchecked_border_color: Option<Rgba>,
241    /// Checkbox label font specification.
242    #[theme(nested, resolved_type = "ResolvedFontSpec")]
243    pub font: Option<FontSpec>,
244    /// Checkbox border specification.
245    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
246    pub border: Option<WidgetBorderSpec>,
247}
248
249// ── 2.6 Menu ────────────────────────────────────────────────────────────────
250
251/// Popup and context menu appearance.
252#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
253#[serde_with::skip_serializing_none]
254#[serde(default)]
255#[theme_layer(border_kind = "none")]
256#[theme_inherit(font = "font")]
257pub struct MenuTheme {
258    /// Menu panel background fill.
259    #[theme(inherit_from = "defaults.background_color")]
260    pub background_color: Option<Rgba>,
261    /// Separator line color between menu items.
262    #[theme(inherit_from = "defaults.border.color")]
263    pub separator_color: Option<Rgba>,
264    /// Height of a single menu item row. `None` where the platform states
265    /// no row height: KDE's menu items size to their font
266    /// (docs/platform-facts.md §2.6).
267    #[serde(rename = "row_height_px")]
268    #[theme(category = "soft_option", check = "non_negative")]
269    pub row_height: Option<f32>,
270    /// Space between a menu item's icon and its label.
271    #[serde(rename = "icon_text_gap_px")]
272    #[theme(check = "non_negative")]
273    pub icon_text_gap: Option<f32>,
274    /// Menu item icon size in logical pixels.
275    #[serde(rename = "icon_size_px")]
276    #[theme(check = "non_negative", inherit_from = "defaults.icon_sizes.toolbar")]
277    pub icon_size: Option<f32>,
278    /// Menu item background on hover.
279    #[theme(inherit_from = "defaults.selection_background")]
280    pub hover_background: Option<Rgba>,
281    /// Menu item text color on hover.
282    #[theme(inherit_from = "defaults.selection_text_color")]
283    pub hover_text_color: Option<Rgba>,
284    /// Disabled menu item text color.
285    #[theme(inherit_from = "defaults.disabled_text_color")]
286    pub disabled_text_color: Option<Rgba>,
287    /// Menu item font specification.
288    #[theme(nested, resolved_type = "ResolvedFontSpec")]
289    pub font: Option<FontSpec>,
290    /// Menu border specification.
291    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
292    pub border: Option<WidgetBorderSpec>,
293}
294
295// ── 2.7 Tooltip ─────────────────────────────────────────────────────────────
296
297/// Tooltip popup appearance.
298#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
299#[serde_with::skip_serializing_none]
300#[serde(default)]
301#[theme_inherit(border_kind = "full", font = "font")]
302pub struct TooltipTheme {
303    /// Tooltip background fill.
304    #[theme(inherit_from = "defaults.background_color")]
305    pub background_color: Option<Rgba>,
306    /// Maximum tooltip width before wrapping.
307    #[serde(rename = "max_width_px")]
308    #[theme(check = "non_negative")]
309    pub max_width: Option<f32>,
310    /// Tooltip font specification.
311    #[theme(nested, resolved_type = "ResolvedFontSpec")]
312    pub font: Option<FontSpec>,
313    /// Tooltip border specification.
314    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
315    pub border: Option<WidgetBorderSpec>,
316}
317
318// ── 2.8 Scrollbar ───────────────────────────────────────────────────────────
319
320/// Scrollbar colors and geometry.
321#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
322#[serde_with::skip_serializing_none]
323#[serde(default)]
324pub struct ScrollbarTheme {
325    /// Scrollbar track (gutter) color.
326    pub track_color: Option<Rgba>,
327    /// Scrollbar thumb color.
328    #[theme(inherit_from = "defaults.muted_color")]
329    pub thumb_color: Option<Rgba>,
330    /// Thumb color on hover.
331    #[theme(inherit_from = "defaults.muted_color")]
332    pub thumb_hover_color: Option<Rgba>,
333    /// Scrollbar groove width in logical pixels.
334    #[serde(rename = "groove_width_px")]
335    #[theme(check = "non_negative")]
336    pub groove_width: Option<f32>,
337    /// Minimum thumb length in logical pixels.
338    #[serde(rename = "min_thumb_length_px")]
339    #[theme(check = "non_negative")]
340    pub min_thumb_length: Option<f32>,
341    /// Width of the thumb rail within the scrollbar.
342    #[serde(rename = "thumb_width_px")]
343    #[theme(check = "non_negative")]
344    pub thumb_width: Option<f32>,
345    /// Whether the scrollbar overlays content instead of taking layout space.
346    pub overlay_mode: Option<bool>,
347    /// Thumb color when pressed/dragging.
348    #[theme(category = "soft_option")]
349    pub thumb_active_color: Option<Rgba>,
350}
351
352// ── 2.9 Slider ──────────────────────────────────────────────────────────────
353
354/// Slider control colors and geometry.
355#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
356#[serde_with::skip_serializing_none]
357#[serde(default)]
358pub struct SliderTheme {
359    /// Filled portion of the slider track.
360    #[theme(inherit_from = "defaults.accent_color")]
361    pub fill_color: Option<Rgba>,
362    /// Unfilled track color.
363    #[theme(inherit_from = "defaults.muted_color")]
364    pub track_color: Option<Rgba>,
365    /// Thumb (handle) color.
366    #[theme(inherit_from = "defaults.surface_color")]
367    pub thumb_color: Option<Rgba>,
368    /// Track height in logical pixels.
369    #[serde(rename = "track_height_px")]
370    #[theme(check = "non_negative")]
371    pub track_height: Option<f32>,
372    /// Thumb diameter in logical pixels.
373    #[serde(rename = "thumb_diameter_px")]
374    #[theme(check = "non_negative")]
375    pub thumb_diameter: Option<f32>,
376    /// Tick mark length in logical pixels.
377    #[serde(rename = "tick_mark_length_px")]
378    #[theme(check = "non_negative")]
379    pub tick_mark_length: Option<f32>,
380    /// Opacity multiplier when disabled (0.0-1.0), applied to the whole
381    /// widget on top of its disabled colours (see
382    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
383    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
384    pub disabled_opacity: Option<f32>,
385    /// Thumb color on hover.
386    #[theme(category = "soft_option")]
387    pub thumb_hover_color: Option<Rgba>,
388    /// Filled track color when disabled; `None`, the enabled colour (as for
389    /// the two below).
390    #[theme(category = "soft_option")]
391    pub disabled_fill_color: Option<Rgba>,
392    /// Unfilled track color when disabled.
393    #[theme(category = "soft_option")]
394    pub disabled_track_color: Option<Rgba>,
395    /// Thumb color when disabled.
396    #[theme(category = "soft_option")]
397    pub disabled_thumb_color: Option<Rgba>,
398}
399
400// ── 2.10 Progress Bar ───────────────────────────────────────────────────────
401
402/// Progress bar colors and geometry.
403#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
404#[serde_with::skip_serializing_none]
405#[serde(default)]
406#[theme_inherit(border_kind = "full")]
407pub struct ProgressBarTheme {
408    /// Filled progress bar color.
409    #[theme(inherit_from = "defaults.accent_color")]
410    pub fill_color: Option<Rgba>,
411    /// Background track color.
412    #[theme(inherit_from = "defaults.muted_color")]
413    pub track_color: Option<Rgba>,
414    /// Bar height in logical pixels.
415    #[serde(rename = "track_height_px")]
416    #[theme(check = "non_negative")]
417    pub track_height: Option<f32>,
418    /// Minimum bar width in logical pixels.
419    #[serde(rename = "min_width_px")]
420    #[theme(check = "non_negative")]
421    pub min_width: Option<f32>,
422    /// Progress bar border specification.
423    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
424    pub border: Option<WidgetBorderSpec>,
425}
426
427// ── 2.11 Tab Bar ─────────────────────────────────────────────────────────────
428
429/// Tab bar colors and sizing.
430#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
431#[serde_with::skip_serializing_none]
432#[serde(default)]
433#[theme_layer(border_kind = "none")]
434#[theme_inherit(font = "font")]
435pub struct TabTheme {
436    /// Inactive tab background.
437    #[theme(inherit_from = "defaults.background_color")]
438    pub background_color: Option<Rgba>,
439    /// Active (selected) tab background.
440    #[theme(inherit_from = "defaults.background_color")]
441    pub active_background: Option<Rgba>,
442    /// Active (selected) tab text color.
443    #[theme(inherit_from = "defaults.text_color")]
444    pub active_text_color: Option<Rgba>,
445    /// Tab bar strip background.
446    #[theme(inherit_from = "defaults.background_color")]
447    pub bar_background: Option<Rgba>,
448    /// Minimum tab width in logical pixels.
449    #[serde(rename = "min_width_px")]
450    #[theme(check = "non_negative")]
451    pub min_width: Option<f32>,
452    /// Minimum tab height in logical pixels.
453    #[serde(rename = "min_height_px")]
454    #[theme(check = "non_negative")]
455    pub min_height: Option<f32>,
456    /// Space between adjacent tabs in logical pixels. `None` where the
457    /// platform states none: AppKit publishes no tab spacing
458    /// (docs/platform-facts.md §2.11).
459    #[serde(rename = "item_gap_px")]
460    #[theme(category = "soft_option", check = "non_negative")]
461    pub item_gap: Option<f32>,
462    /// Color of the line that marks the active tab: Breeze's `Highlight`
463    /// strip, libadwaita's accent, Material's `primary` indicator. `None`
464    /// where the platform marks the active tab without one, by its fill and
465    /// border (docs/platform-facts.md §2.11); a connector draws the line only
466    /// where the theme states its color, width and side.
467    #[theme(category = "soft_option")]
468    pub active_indicator_color: Option<Rgba>,
469    /// Thickness of the active tab's indicator line in logical pixels:
470    /// KDE 3, GNOME 4, Material 3 (docs/platform-facts.md §2.11).
471    #[serde(rename = "active_indicator_width_px")]
472    #[theme(category = "soft_option", check = "non_negative")]
473    pub active_indicator_width: Option<f32>,
474    /// Which edge of the active tab its indicator line lies along, for tabs
475    /// above their pages: KDE top, GNOME and Material bottom
476    /// (docs/platform-facts.md §2.11).
477    #[theme(category = "soft_option")]
478    pub active_indicator_side: Option<TabIndicatorSide>,
479    /// Tab text color on hover.
480    pub hover_text_color: Option<Rgba>,
481    /// Tab background on hover.
482    #[theme(category = "soft_option")]
483    pub hover_background: Option<Rgba>,
484    /// Tab font specification.
485    #[theme(nested, resolved_type = "ResolvedFontSpec")]
486    pub font: Option<FontSpec>,
487    /// Tab border specification.
488    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
489    pub border: Option<WidgetBorderSpec>,
490}
491
492// ── 2.12 Sidebar ─────────────────────────────────────────────────────────────
493
494/// Sidebar panel background, selection, and hover colors.
495#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
496#[serde_with::skip_serializing_none]
497#[serde(default)]
498#[theme_layer(border_kind = "partial")]
499#[theme_inherit(border_kind = "partial", font = "font")]
500pub struct SidebarTheme {
501    /// Sidebar panel background fill.
502    #[theme(inherit_from = "defaults.background_color")]
503    pub background_color: Option<Rgba>,
504    /// Selected item background color.
505    #[theme(inherit_from = "defaults.selection_background")]
506    pub selection_background: Option<Rgba>,
507    /// Selected item text color.
508    #[theme(inherit_from = "defaults.selection_text_color")]
509    pub selection_text_color: Option<Rgba>,
510    /// Hovered item background color.
511    #[theme(inherit_from = "defaults.background_color")]
512    pub hover_background: Option<Rgba>,
513    /// Sidebar font specification.
514    #[theme(nested, resolved_type = "ResolvedFontSpec")]
515    pub font: Option<FontSpec>,
516    /// Sidebar border specification.
517    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
518    pub border: Option<WidgetBorderSpec>,
519}
520
521// ── 2.13 Toolbar ─────────────────────────────────────────────────────────────
522
523/// Toolbar sizing, spacing, and font.
524#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
525#[serde_with::skip_serializing_none]
526#[serde(default)]
527#[theme_inherit(border_kind = "full", font = "font")]
528pub struct ToolbarTheme {
529    /// Toolbar background color.
530    #[theme(inherit_from = "defaults.background_color")]
531    pub background_color: Option<Rgba>,
532    /// Toolbar height in logical pixels. `None` where the platform states
533    /// no toolbar height: KDE's toolbar sizes to its content, and GNOME's
534    /// `.toolbar` sets no minimum height (docs/platform-facts.md §2.13).
535    #[serde(rename = "bar_height_px")]
536    #[theme(category = "soft_option", check = "non_negative")]
537    pub bar_height: Option<f32>,
538    /// Horizontal space between toolbar items. `None` where the theme cites
539    /// no source for one (docs/platform-facts.md §2.13 states it for every
540    /// platform, but a colour scheme has none).
541    #[serde(rename = "item_gap_px")]
542    #[theme(category = "soft_option", check = "non_negative")]
543    pub item_gap: Option<f32>,
544    /// Toolbar icon size in logical pixels.
545    #[serde(rename = "icon_size_px")]
546    #[theme(check = "non_negative", inherit_from = "defaults.icon_sizes.toolbar")]
547    pub icon_size: Option<f32>,
548    /// Toolbar label font specification.
549    #[theme(nested, resolved_type = "ResolvedFontSpec")]
550    pub font: Option<FontSpec>,
551    /// Toolbar border specification.
552    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
553    pub border: Option<WidgetBorderSpec>,
554}
555
556// ── 2.14 Status Bar ──────────────────────────────────────────────────────────
557
558/// Status bar font and background.
559#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
560#[serde_with::skip_serializing_none]
561#[serde(default)]
562#[theme_layer(border_kind = "partial")]
563#[theme_inherit(border_kind = "partial", font = "font")]
564pub struct StatusBarTheme {
565    /// Status bar background color.
566    #[theme(inherit_from = "defaults.background_color")]
567    pub background_color: Option<Rgba>,
568    /// Status bar font specification.
569    #[theme(nested, resolved_type = "ResolvedFontSpec")]
570    pub font: Option<FontSpec>,
571    /// Status bar border specification.
572    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
573    pub border: Option<WidgetBorderSpec>,
574}
575
576// ── 2.15 List / Table ────────────────────────────────────────────────────────
577
578/// List and table colors and row geometry.
579#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
580#[serde_with::skip_serializing_none]
581#[serde(default)]
582#[theme_inherit(border_kind = "full", font = "item_font")]
583#[theme_inherit(font = "header_font")]
584pub struct ListTheme {
585    /// List background fill.
586    pub background_color: Option<Rgba>,
587    /// Alternate row background for striped lists.
588    pub alternate_row_background: Option<Rgba>,
589    /// Selected row highlight color.
590    #[theme(inherit_from = "defaults.selection_background")]
591    pub selection_background: Option<Rgba>,
592    /// Text color inside a selected row.
593    #[theme(inherit_from = "defaults.selection_text_color")]
594    pub selection_text_color: Option<Rgba>,
595    /// Column header background fill.
596    #[theme(inherit_from = "defaults.surface_color")]
597    pub header_background: Option<Rgba>,
598    /// Grid line color between rows/columns.
599    #[theme(inherit_from = "defaults.border.color")]
600    pub grid_color: Option<Rgba>,
601    /// Row height in logical pixels. `None` where the platform states no row
602    /// height: KDE's rows size to their content, and GNOME's plain list sets
603    /// no minimum height (docs/platform-facts.md §2.15).
604    #[serde(rename = "row_height_px")]
605    #[theme(category = "soft_option", check = "non_negative")]
606    pub row_height: Option<f32>,
607    /// Hovered row background color.
608    #[theme(inherit_from = "defaults.background_color")]
609    pub hover_background: Option<Rgba>,
610    /// Hovered row text color.
611    pub hover_text_color: Option<Rgba>,
612    /// Disabled row text color.
613    #[theme(inherit_from = "defaults.disabled_text_color")]
614    pub disabled_text_color: Option<Rgba>,
615    /// List item font specification.
616    #[theme(nested, resolved_type = "ResolvedFontSpec")]
617    pub item_font: Option<FontSpec>,
618    /// Column header font specification.
619    #[theme(nested, resolved_type = "ResolvedFontSpec")]
620    pub header_font: Option<FontSpec>,
621    /// List border specification.
622    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
623    pub border: Option<WidgetBorderSpec>,
624}
625
626// ── 2.16 Popover / Dropdown ──────────────────────────────────────────────────
627
628/// Popover / dropdown panel appearance.
629#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
630#[serde_with::skip_serializing_none]
631#[serde(default)]
632#[theme_inherit(border_kind = "full_lg", font = "font")]
633pub struct PopoverTheme {
634    /// Panel background fill.
635    pub background_color: Option<Rgba>,
636    /// Popover font specification.
637    #[theme(nested, resolved_type = "ResolvedFontSpec")]
638    pub font: Option<FontSpec>,
639    /// Popover border specification.
640    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
641    pub border: Option<WidgetBorderSpec>,
642}
643
644// ── 2.17 Splitter ────────────────────────────────────────────────────────────
645
646/// Splitter handle width and color.
647#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
648#[serde_with::skip_serializing_none]
649#[serde(default)]
650pub struct SplitterTheme {
651    /// Handle width in logical pixels.
652    #[serde(rename = "divider_width_px")]
653    #[theme(check = "non_negative")]
654    pub divider_width: Option<f32>,
655    /// Divider color.
656    #[theme(inherit_from = "defaults.border.color")]
657    pub divider_color: Option<Rgba>,
658    /// Divider color on hover.
659    pub hover_color: Option<Rgba>,
660}
661
662// ── 2.18 Separator ───────────────────────────────────────────────────────────
663
664/// Separator line color and width.
665#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
666#[serde_with::skip_serializing_none]
667#[serde(default)]
668pub struct SeparatorTheme {
669    /// Separator line color.
670    #[theme(inherit_from = "defaults.border.color")]
671    pub line_color: Option<Rgba>,
672    /// Separator line width in logical pixels.
673    #[serde(rename = "line_width_px")]
674    #[theme(check = "non_negative", inherit_from = "defaults.border.line_width")]
675    pub line_width: Option<f32>,
676}
677
678// ── 2.21 Switch / Toggle ─────────────────────────────────────────────────────
679
680/// Toggle switch track, thumb, geometry, and interactive states.
681#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
682#[serde_with::skip_serializing_none]
683#[serde(default)]
684pub struct SwitchTheme {
685    /// Track background when the switch is on.
686    #[theme(inherit_from = "defaults.accent_color")]
687    pub checked_background: Option<Rgba>,
688    /// Track background when the switch is off.
689    pub unchecked_background: Option<Rgba>,
690    /// Thumb (knob) color; while the switch is off, `unchecked_thumb_background`
691    /// where the theme states one.
692    #[theme(inherit_from = "defaults.surface_color")]
693    pub thumb_background: Option<Rgba>,
694    /// Thumb color while the switch is off, where the platform colours it
695    /// apart: Material's `outline`, WinUI's `TextFillColorSecondary`,
696    /// libadwaita's `$slider_color`. `None` where the thumb is one colour in
697    /// both states, `thumb_background` (docs/platform-facts.md §2.21).
698    #[theme(category = "soft_option")]
699    pub unchecked_thumb_background: Option<Rgba>,
700    /// Track width in logical pixels.
701    #[serde(rename = "track_width_px")]
702    #[theme(check = "non_negative")]
703    pub track_width: Option<f32>,
704    /// Track height in logical pixels.
705    #[serde(rename = "track_height_px")]
706    #[theme(check = "non_negative")]
707    pub track_height: Option<f32>,
708    /// Thumb diameter in logical pixels; while the switch is off,
709    /// `unchecked_thumb_diameter` where the theme states one.
710    #[serde(rename = "thumb_diameter_px")]
711    #[theme(check = "non_negative")]
712    pub thumb_diameter: Option<f32>,
713    /// Thumb diameter while the switch is off, in logical pixels, where the
714    /// platform sizes it apart: Material's is 16 off, 24 on. `None` where the
715    /// thumb is one size in both states, `thumb_diameter`
716    /// (docs/platform-facts.md §2.21).
717    #[serde(rename = "unchecked_thumb_diameter_px")]
718    #[theme(category = "soft_option", check = "non_negative")]
719    pub unchecked_thumb_diameter: Option<f32>,
720    /// Track corner radius in logical pixels.
721    #[serde(rename = "track_radius_px")]
722    #[theme(check = "non_negative")]
723    pub track_radius: Option<f32>,
724    /// Opacity multiplier when disabled (0.0-1.0), applied to the whole
725    /// widget on top of its disabled colours (see
726    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
727    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
728    pub disabled_opacity: Option<f32>,
729    /// Track hover color when checked (on).
730    #[theme(category = "soft_option")]
731    pub hover_checked_background: Option<Rgba>,
732    /// Track hover color when unchecked (off).
733    #[theme(category = "soft_option")]
734    pub hover_unchecked_background: Option<Rgba>,
735    /// Track color when disabled and checked; `None`, the enabled colour (as
736    /// for the two below).
737    #[theme(category = "soft_option")]
738    pub disabled_checked_background: Option<Rgba>,
739    /// Track color when disabled and unchecked.
740    #[theme(category = "soft_option")]
741    pub disabled_unchecked_background: Option<Rgba>,
742    /// Thumb color when disabled.
743    #[theme(category = "soft_option")]
744    pub disabled_thumb_color: Option<Rgba>,
745}
746
747// ── 2.22 Dialog ──────────────────────────────────────────────────────────────
748
749/// Dialog sizing, spacing, button order, fonts, border, and background.
750#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
751#[serde_with::skip_serializing_none]
752#[serde(default)]
753#[theme_inherit(border_kind = "full_lg", font = "title_font")]
754#[theme_inherit(font = "body_font")]
755pub struct DialogTheme {
756    /// Dialog background color.
757    pub background_color: Option<Rgba>,
758    /// Minimum dialog width in logical pixels.
759    #[serde(rename = "min_width_px")]
760    #[theme(check = "non_negative", min_max_pair = "max_width")]
761    pub min_width: Option<f32>,
762    /// Maximum dialog width in logical pixels.
763    #[serde(rename = "max_width_px")]
764    #[theme(check = "non_negative")]
765    pub max_width: Option<f32>,
766    /// Minimum dialog height in logical pixels.
767    #[serde(rename = "min_height_px")]
768    #[theme(check = "non_negative", min_max_pair = "max_height")]
769    pub min_height: Option<f32>,
770    /// Maximum dialog height in logical pixels.
771    #[serde(rename = "max_height_px")]
772    #[theme(check = "non_negative")]
773    pub max_height: Option<f32>,
774    /// Horizontal space between dialog buttons.
775    #[serde(rename = "button_gap_px")]
776    #[theme(check = "non_negative")]
777    pub button_gap: Option<f32>,
778    /// Icon size for dialog type icons (warning, error, etc.).
779    #[serde(rename = "icon_size_px")]
780    #[theme(check = "non_negative")]
781    pub icon_size: Option<f32>,
782    /// Platform button order convention (e.g., OK/Cancel vs Cancel/OK).
783    pub button_order: Option<DialogButtonOrder>,
784    /// Dialog title font specification.
785    #[theme(nested, resolved_type = "ResolvedFontSpec")]
786    pub title_font: Option<FontSpec>,
787    /// Dialog body font specification.
788    #[theme(nested, resolved_type = "ResolvedFontSpec")]
789    pub body_font: Option<FontSpec>,
790    /// Dialog border specification.
791    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
792    pub border: Option<WidgetBorderSpec>,
793}
794
795// ── 2.23 Spinner / Progress Ring ─────────────────────────────────────────────
796
797/// Spinner / indeterminate progress indicator.
798#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
799#[serde_with::skip_serializing_none]
800#[serde(default)]
801pub struct SpinnerTheme {
802    /// Spinner arc fill color.
803    pub fill_color: Option<Rgba>,
804    /// Spinner outer diameter in logical pixels.
805    #[serde(rename = "diameter_px")]
806    #[theme(check = "non_negative")]
807    pub diameter: Option<f32>,
808    /// Minimum rendered size in logical pixels.
809    #[serde(rename = "min_diameter_px")]
810    #[theme(check = "non_negative")]
811    pub min_diameter: Option<f32>,
812    /// Arc stroke width in logical pixels.
813    #[serde(rename = "stroke_width_px")]
814    #[theme(check = "non_negative")]
815    pub stroke_width: Option<f32>,
816}
817
818// ── 2.24 ComboBox / Dropdown Trigger ─────────────────────────────────────────
819
820/// ComboBox / dropdown trigger sizing.
821#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
822#[serde_with::skip_serializing_none]
823#[serde(default)]
824#[theme_inherit(border_kind = "full", font = "font")]
825pub struct ComboBoxTheme {
826    /// ComboBox background color.
827    #[theme(inherit_from = "defaults.background_color")]
828    pub background_color: Option<Rgba>,
829    /// Minimum trigger height in logical pixels.
830    #[serde(rename = "min_height_px")]
831    #[theme(check = "non_negative")]
832    pub min_height: Option<f32>,
833    /// Minimum trigger width in logical pixels.
834    #[serde(rename = "min_width_px")]
835    #[theme(check = "non_negative")]
836    pub min_width: Option<f32>,
837    /// Dropdown arrow size in logical pixels.
838    #[serde(rename = "arrow_icon_size_px")]
839    #[theme(check = "non_negative")]
840    pub arrow_icon_size: Option<f32>,
841    /// Width of the arrow clickable area, in logical pixels. `None` where the
842    /// theme states no width (docs/platform-facts.md §2.24): GNOME's dropdown
843    /// draws its arrow inline, macOS's width is only a measured range
844    /// (~16–18px), and the iOS and Material platform presets and the community
845    /// colour-scheme presets cite none.
846    #[serde(rename = "arrow_area_width_px")]
847    #[theme(category = "soft_option", check = "non_negative")]
848    pub arrow_area_width: Option<f32>,
849    /// Opacity multiplier when disabled (0.0-1.0), applied to the whole
850    /// widget on top of its disabled colours (see
851    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
852    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
853    pub disabled_opacity: Option<f32>,
854    /// ComboBox text color when disabled.
855    #[theme(inherit_from = "defaults.disabled_text_color")]
856    pub disabled_text_color: Option<Rgba>,
857    /// ComboBox background on hover.
858    #[theme(category = "soft_option")]
859    pub hover_background: Option<Rgba>,
860    /// ComboBox background when disabled; `None`, the enabled background.
861    #[theme(category = "soft_option")]
862    pub disabled_background: Option<Rgba>,
863    /// ComboBox font specification.
864    #[theme(nested, resolved_type = "ResolvedFontSpec")]
865    pub font: Option<FontSpec>,
866    /// ComboBox border specification.
867    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
868    pub border: Option<WidgetBorderSpec>,
869}
870
871// ── 2.25 Segmented Control ───────────────────────────────────────────────────
872
873/// Segmented control sizing (macOS-primary; KDE uses tab bar metrics as proxy).
874#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
875#[serde_with::skip_serializing_none]
876#[serde(default)]
877#[theme_inherit(border_kind = "full", font = "font")]
878pub struct SegmentedControlTheme {
879    /// Segmented control background color.
880    #[theme(inherit_from = "defaults.background_color")]
881    pub background_color: Option<Rgba>,
882    /// Active segment background.
883    #[theme(inherit_from = "defaults.accent_color")]
884    pub active_background: Option<Rgba>,
885    /// Active segment text color.
886    #[theme(inherit_from = "defaults.accent_text_color")]
887    pub active_text_color: Option<Rgba>,
888    /// Segment height in logical pixels.
889    #[serde(rename = "segment_height_px")]
890    #[theme(check = "non_negative")]
891    pub segment_height: Option<f32>,
892    /// Width of the separator between segments.
893    #[serde(rename = "separator_width_px")]
894    #[theme(check = "non_negative")]
895    pub separator_width: Option<f32>,
896    /// Opacity multiplier when disabled (0.0-1.0), applied to the whole
897    /// widget on top of its disabled colours (see
898    /// [`ThemeDefaults::disabled_opacity`](crate::model::ThemeDefaults::disabled_opacity)).
899    #[theme(range = "0.0..=1.0", inherit_from = "defaults.disabled_opacity")]
900    pub disabled_opacity: Option<f32>,
901    /// Segment background on hover.
902    #[theme(category = "soft_option")]
903    pub hover_background: Option<Rgba>,
904    /// Segmented control font specification.
905    #[theme(nested, resolved_type = "ResolvedFontSpec")]
906    pub font: Option<FontSpec>,
907    /// Segmented control border specification.
908    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
909    pub border: Option<WidgetBorderSpec>,
910}
911
912// ── 2.26 Card / Container ────────────────────────────────────────────────────
913
914/// Card / container colors and geometry.
915#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
916#[serde_with::skip_serializing_none]
917#[serde(default)]
918#[theme_layer(border_kind = "none")]
919pub struct CardTheme {
920    /// Card background fill.
921    #[theme(inherit_from = "defaults.surface_color")]
922    pub background_color: Option<Rgba>,
923    /// Card border specification.
924    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
925    pub border: Option<WidgetBorderSpec>,
926}
927
928// ── 2.27 Expander / Disclosure ───────────────────────────────────────────────
929
930/// Expander / disclosure row geometry.
931#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
932#[serde_with::skip_serializing_none]
933#[serde(default)]
934#[theme_inherit(border_kind = "full", font = "font")]
935pub struct ExpanderTheme {
936    /// Collapsed header row height in logical pixels.
937    #[serde(rename = "header_height_px")]
938    #[theme(check = "non_negative")]
939    pub header_height: Option<f32>,
940    /// Disclosure arrow size in logical pixels.
941    #[serde(rename = "arrow_icon_size_px")]
942    #[theme(check = "non_negative")]
943    pub arrow_icon_size: Option<f32>,
944    /// Expander header background on hover.
945    #[theme(category = "soft_option")]
946    pub hover_background: Option<Rgba>,
947    /// Disclosure arrow/chevron color.
948    #[theme(category = "soft_option")]
949    pub arrow_color: Option<Rgba>,
950    /// Which end of the header row the disclosure arrow sits at: before the
951    /// title, or at the row's end. `None` where the theme states none
952    /// (docs/platform-facts.md §2.27).
953    #[theme(category = "soft_option")]
954    pub arrow_side: Option<ArrowSide>,
955    /// Space between the arrow's icon box (`arrow_icon_size` square) and the
956    /// title after it, or, for a trailing arrow, the header content before
957    /// it, in logical pixels. `None` where the platform states none: AppKit
958    /// documents no disclosure geometry (docs/platform-facts.md §2.27).
959    #[serde(rename = "arrow_gap_px")]
960    #[theme(category = "soft_option", check = "non_negative")]
961    pub arrow_gap: Option<f32>,
962    /// Horizontal distance from the expander's leading inner edge (inside
963    /// its frame, where it has one) to the content it reveals, in logical
964    /// pixels. `None` where the platform states none: AppKit documents no
965    /// disclosure geometry (docs/platform-facts.md §2.27).
966    #[serde(rename = "content_indent_px")]
967    #[theme(category = "soft_option", check = "non_negative")]
968    pub content_indent: Option<f32>,
969    /// Whether the expander draws a frame (its `border`) around its header
970    /// and the content it reveals: KDE's has none, libadwaita's and WinUI's
971    /// are framed. `None` where the theme states none
972    /// (docs/platform-facts.md §2.27).
973    #[theme(category = "soft_option")]
974    pub frame_enabled: Option<bool>,
975    /// Expander font specification.
976    #[theme(nested, resolved_type = "ResolvedFontSpec")]
977    pub font: Option<FontSpec>,
978    /// Expander border specification.
979    #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
980    pub border: Option<WidgetBorderSpec>,
981}
982
983// ── 2.28 Link ────────────────────────────────────────────────────────────────
984
985/// Hyperlink colors and underline setting.
986#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget)]
987#[serde_with::skip_serializing_none]
988#[serde(default)]
989#[theme_inherit(font = "font")]
990pub struct LinkTheme {
991    /// Visited link text color.
992    #[theme(inherit_from = "defaults.link_color")]
993    pub visited_text_color: Option<Rgba>,
994    /// Whether links are underlined.
995    pub underline_enabled: Option<bool>,
996    /// Link background fill (typically transparent).
997    pub background_color: Option<Rgba>,
998    /// Link background on hover.
999    pub hover_background: Option<Rgba>,
1000    /// Link text color on hover.
1001    pub hover_text_color: Option<Rgba>,
1002    /// Link text color when pressed/active.
1003    pub active_text_color: Option<Rgba>,
1004    /// Link text color when disabled.
1005    #[theme(inherit_from = "defaults.disabled_text_color")]
1006    pub disabled_text_color: Option<Rgba>,
1007    /// Link font specification.
1008    #[theme(nested, resolved_type = "ResolvedFontSpec")]
1009    pub font: Option<FontSpec>,
1010}
1011
1012// -- Layout (top-level, not per-variant) ------------------------------------------
1013
1014/// Layout spacing constants shared between light and dark variants.
1015///
1016/// Unlike other widget themes, LayoutTheme lives on [`crate::Theme`] (top-level)
1017/// rather than [`crate::ThemeMode`] because spacing is variant-independent.
1018// Phase 93-05 G5: LayoutTheme keeps `skip_inventory` so it does NOT register
1019// in the per-variant WidgetFieldInfo registry (it is top-level, not per-variant),
1020// but also derives ThemeFields so lint_toml can look up its fields in the
1021// non-widget FieldInfo registry under the key "LayoutTheme".
1022#[derive(
1023    Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget, ThemeFields,
1024)]
1025#[serde_with::skip_serializing_none]
1026#[serde(default)]
1027#[theme_layer(skip_inventory)]
1028pub struct LayoutTheme {
1029    /// Space between adjacent widgets in logical pixels.
1030    #[serde(rename = "widget_gap_px")]
1031    #[theme(check = "non_negative")]
1032    pub widget_gap: Option<f32>,
1033    /// Padding inside containers in logical pixels.
1034    #[serde(rename = "container_margin_px")]
1035    #[theme(check = "non_negative")]
1036    pub container_margin: Option<f32>,
1037    /// Padding inside the main window in logical pixels.
1038    #[serde(rename = "window_margin_px")]
1039    #[theme(check = "non_negative")]
1040    pub window_margin: Option<f32>,
1041    /// Space between major content sections in logical pixels.
1042    #[serde(rename = "section_gap_px")]
1043    #[theme(check = "non_negative")]
1044    pub section_gap: Option<f32>,
1045}
1046
1047#[cfg(test)]
1048#[allow(clippy::unwrap_used, clippy::expect_used, dead_code)]
1049mod tests {
1050    use super::*;
1051    use crate::Rgba;
1052    use crate::model::border::{ResolvedWidgetBorder, WidgetBorderSpec};
1053    use crate::model::font::FontSize;
1054    use crate::model::{DialogButtonOrder, FontSpec};
1055
1056    // Test widget using derive (validates derive macro works in test context)
1057    /// Test widget for macro verification.
1058    #[derive(
1059        Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget,
1060    )]
1061    #[serde_with::skip_serializing_none]
1062    #[serde(default)]
1063    #[theme_layer(skip_inventory)]
1064    pub struct TestWidget {
1065        pub size: Option<f32>,
1066        pub label: Option<String>,
1067        #[theme(nested, resolved_type = "ResolvedFontSpec")]
1068        pub font: Option<FontSpec>,
1069    }
1070
1071    // === ResolvedFontSpec tests ===
1072
1073    #[test]
1074    fn resolved_font_spec_fields_are_concrete() {
1075        let rfs = ResolvedFontSpec {
1076            family: "Inter".into(),
1077            size: 14.0,
1078            defined_size: Some(crate::model::font::FontSize::Px(14.0)),
1079            weight: 400,
1080            style: crate::model::font::FontStyle::Normal,
1081            color: crate::Rgba::rgb(0, 0, 0),
1082        };
1083        assert_eq!(rfs.family.as_ref(), "Inter");
1084        assert_eq!(rfs.size, 14.0);
1085        assert_eq!(rfs.weight, 400);
1086    }
1087
1088    // === derive(ThemeWidget) generated struct tests ===
1089
1090    #[test]
1091    fn generated_option_struct_has_option_fields() {
1092        let w = TestWidget::default();
1093        assert!(w.size.is_none());
1094        assert!(w.label.is_none());
1095        assert!(w.font.is_none());
1096    }
1097
1098    #[test]
1099    fn generated_option_struct_is_empty_by_default() {
1100        assert!(TestWidget::default().is_empty());
1101    }
1102
1103    #[test]
1104    fn generated_option_struct_not_empty_when_size_set() {
1105        let w = TestWidget {
1106            size: Some(24.0),
1107            ..Default::default()
1108        };
1109        assert!(!w.is_empty());
1110    }
1111
1112    #[test]
1113    fn generated_option_struct_not_empty_when_font_set() {
1114        let w = TestWidget {
1115            font: Some(FontSpec {
1116                size: Some(FontSize::Px(14.0)),
1117                ..Default::default()
1118            }),
1119            ..Default::default()
1120        };
1121        assert!(!w.is_empty());
1122    }
1123
1124    #[test]
1125    fn generated_resolved_struct_has_concrete_fields() {
1126        let resolved = ResolvedTestWidget {
1127            size: 24.0,
1128            label: "Click me".into(),
1129            font: ResolvedFontSpec {
1130                family: "Inter".into(),
1131                size: 14.0,
1132                defined_size: Some(crate::model::font::FontSize::Px(14.0)),
1133                weight: 400,
1134                style: crate::model::font::FontStyle::Normal,
1135                color: crate::Rgba::rgb(0, 0, 0),
1136            },
1137        };
1138        assert_eq!(resolved.size, 24.0);
1139        assert_eq!(resolved.label, "Click me");
1140        assert_eq!(resolved.font.family.as_ref(), "Inter");
1141    }
1142
1143    // === merge tests for generated structs ===
1144
1145    #[test]
1146    fn generated_merge_option_field_overlay_wins() {
1147        let mut base = TestWidget {
1148            size: Some(20.0),
1149            ..Default::default()
1150        };
1151        let overlay = TestWidget {
1152            size: Some(24.0),
1153            ..Default::default()
1154        };
1155        base.merge(&overlay);
1156        assert_eq!(base.size, Some(24.0));
1157    }
1158
1159    #[test]
1160    fn generated_merge_option_field_none_preserves_base() {
1161        let mut base = TestWidget {
1162            size: Some(20.0),
1163            ..Default::default()
1164        };
1165        let overlay = TestWidget::default();
1166        base.merge(&overlay);
1167        assert_eq!(base.size, Some(20.0));
1168    }
1169
1170    #[test]
1171    fn generated_merge_optional_nested_both_some_merges_inner() {
1172        let mut base = TestWidget {
1173            font: Some(FontSpec {
1174                family: Some("Noto Sans".into()),
1175                size: Some(FontSize::Px(12.0)),
1176                weight: None,
1177                ..Default::default()
1178            }),
1179            ..Default::default()
1180        };
1181        let overlay = TestWidget {
1182            font: Some(FontSpec {
1183                family: None,
1184                size: None,
1185                weight: Some(700),
1186                ..Default::default()
1187            }),
1188            ..Default::default()
1189        };
1190        base.merge(&overlay);
1191        let font = base.font.as_ref().unwrap();
1192        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
1193        assert_eq!(font.size, Some(FontSize::Px(12.0))); // preserved
1194        assert_eq!(font.weight, Some(700)); // overlay sets
1195    }
1196
1197    #[test]
1198    fn generated_merge_optional_nested_none_plus_some_clones() {
1199        let mut base = TestWidget::default();
1200        let overlay = TestWidget {
1201            font: Some(FontSpec {
1202                family: Some("Inter".into()),
1203                size: Some(FontSize::Px(14.0)),
1204                weight: Some(400),
1205                ..Default::default()
1206            }),
1207            ..Default::default()
1208        };
1209        base.merge(&overlay);
1210        let font = base.font.as_ref().unwrap();
1211        assert_eq!(font.family.as_deref(), Some("Inter"));
1212        assert_eq!(font.size, Some(FontSize::Px(14.0)));
1213        assert_eq!(font.weight, Some(400));
1214    }
1215
1216    #[test]
1217    fn generated_merge_optional_nested_some_plus_none_preserves_base() {
1218        let mut base = TestWidget {
1219            font: Some(FontSpec {
1220                family: Some("Inter".into()),
1221                size: Some(FontSize::Px(14.0)),
1222                weight: Some(400),
1223                ..Default::default()
1224            }),
1225            ..Default::default()
1226        };
1227        let overlay = TestWidget::default();
1228        base.merge(&overlay);
1229        let font = base.font.as_ref().unwrap();
1230        assert_eq!(font.family.as_deref(), Some("Inter"));
1231    }
1232
1233    #[test]
1234    fn generated_merge_optional_nested_none_plus_none_stays_none() {
1235        let mut base = TestWidget::default();
1236        let overlay = TestWidget::default();
1237        base.merge(&overlay);
1238        assert!(base.font.is_none());
1239    }
1240
1241    // === impl_merge! optional_nested clause direct tests ===
1242
1243    // Verify the optional_nested clause directly on a FontSpec-containing struct
1244    #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1245    struct WithFont {
1246        name: Option<String>,
1247        font: Option<FontSpec>,
1248    }
1249
1250    impl_merge!(WithFont {
1251        option { name }
1252        optional_nested { font }
1253    });
1254
1255    #[test]
1256    fn impl_merge_optional_nested_none_none_stays_none() {
1257        let mut base = WithFont::default();
1258        let overlay = WithFont::default();
1259        base.merge(&overlay);
1260        assert!(base.font.is_none());
1261    }
1262
1263    #[test]
1264    fn impl_merge_optional_nested_some_none_preserves_base() {
1265        let mut base = WithFont {
1266            font: Some(FontSpec {
1267                size: Some(FontSize::Px(12.0)),
1268                ..Default::default()
1269            }),
1270            ..Default::default()
1271        };
1272        let overlay = WithFont::default();
1273        base.merge(&overlay);
1274        assert_eq!(base.font.as_ref().unwrap().size, Some(FontSize::Px(12.0)));
1275    }
1276
1277    #[test]
1278    fn impl_merge_optional_nested_none_some_clones_overlay() {
1279        let mut base = WithFont::default();
1280        let overlay = WithFont {
1281            font: Some(FontSpec {
1282                family: Some("Inter".into()),
1283                ..Default::default()
1284            }),
1285            ..Default::default()
1286        };
1287        base.merge(&overlay);
1288        assert_eq!(base.font.as_ref().unwrap().family.as_deref(), Some("Inter"));
1289    }
1290
1291    #[test]
1292    fn impl_merge_optional_nested_some_some_merges_inner() {
1293        let mut base = WithFont {
1294            font: Some(FontSpec {
1295                family: Some("Noto".into()),
1296                size: Some(FontSize::Px(11.0)),
1297                weight: None,
1298                ..Default::default()
1299            }),
1300            ..Default::default()
1301        };
1302        let overlay = WithFont {
1303            font: Some(FontSpec {
1304                family: None,
1305                size: Some(FontSize::Px(14.0)),
1306                weight: Some(400),
1307                ..Default::default()
1308            }),
1309            ..Default::default()
1310        };
1311        base.merge(&overlay);
1312        let f = base.font.as_ref().unwrap();
1313        assert_eq!(f.family.as_deref(), Some("Noto")); // preserved
1314        assert_eq!(f.size, Some(FontSize::Px(14.0))); // overlay wins
1315        assert_eq!(f.weight, Some(400)); // overlay sets
1316    }
1317
1318    #[test]
1319    fn impl_merge_optional_nested_is_empty_none() {
1320        let w = WithFont::default();
1321        assert!(w.is_empty());
1322    }
1323
1324    #[test]
1325    fn impl_merge_optional_nested_is_empty_some_default() {
1326        // Some(FontSpec::default()) with all-None sub-fields counts as empty (D-2 fix).
1327        let w = WithFont {
1328            font: Some(FontSpec::default()),
1329            ..Default::default()
1330        };
1331        assert!(w.is_empty());
1332    }
1333
1334    #[test]
1335    fn impl_merge_optional_nested_is_not_empty_when_populated() {
1336        let w = WithFont {
1337            font: Some(FontSpec {
1338                size: Some(FontSize::Px(14.0)),
1339                ..Default::default()
1340            }),
1341            ..Default::default()
1342        };
1343        assert!(!w.is_empty());
1344    }
1345
1346    // === ButtonTheme tests ===
1347
1348    #[test]
1349    fn button_theme_default_is_empty() {
1350        assert!(ButtonTheme::default().is_empty());
1351    }
1352
1353    #[test]
1354    fn button_theme_not_empty_when_set() {
1355        let b = ButtonTheme {
1356            background_color: Some(Rgba::rgb(200, 200, 200)),
1357            min_width: Some(64.0),
1358            ..Default::default()
1359        };
1360        assert!(!b.is_empty());
1361    }
1362
1363    #[test]
1364    fn button_theme_merge_font_optional_nested() {
1365        let mut base = ButtonTheme {
1366            font: Some(FontSpec {
1367                family: Some("Noto Sans".into()),
1368                size: Some(FontSize::Px(11.0)),
1369                weight: None,
1370                ..Default::default()
1371            }),
1372            ..Default::default()
1373        };
1374        let overlay = ButtonTheme {
1375            font: Some(FontSpec {
1376                family: None,
1377                weight: Some(700),
1378                ..Default::default()
1379            }),
1380            ..Default::default()
1381        };
1382        base.merge(&overlay);
1383        let f = base.font.as_ref().unwrap();
1384        assert_eq!(f.family.as_deref(), Some("Noto Sans")); // preserved
1385        assert_eq!(f.weight, Some(700)); // overlay
1386    }
1387
1388    #[test]
1389    fn button_theme_toml_round_trip_with_font_and_border() {
1390        let b = ButtonTheme {
1391            background_color: Some(Rgba::rgb(200, 200, 200)),
1392            font: Some(FontSpec {
1393                family: Some("Inter".into()),
1394                size: Some(FontSize::Px(14.0)),
1395                weight: Some(400),
1396                ..Default::default()
1397            }),
1398            border: Some(WidgetBorderSpec {
1399                corner_radius: Some(4.0),
1400                ..Default::default()
1401            }),
1402            ..Default::default()
1403        };
1404        let toml_str = toml::to_string(&b).unwrap();
1405        let b2: ButtonTheme = toml::from_str(&toml_str).unwrap();
1406        assert_eq!(b, b2);
1407    }
1408
1409    // === WindowTheme tests ===
1410
1411    #[test]
1412    fn window_theme_has_new_fields() {
1413        let w = WindowTheme {
1414            inactive_title_bar_background: Some(Rgba::rgb(180, 180, 180)),
1415            inactive_title_bar_text_color: Some(Rgba::rgb(120, 120, 120)),
1416            title_bar_font: Some(FontSpec {
1417                weight: Some(700),
1418                ..Default::default()
1419            }),
1420            border: Some(WidgetBorderSpec {
1421                corner_radius: Some(4.0),
1422                shadow_enabled: Some(true),
1423                ..Default::default()
1424            }),
1425            ..Default::default()
1426        };
1427        assert!(!w.is_empty());
1428        assert!(w.inactive_title_bar_background.is_some());
1429        assert!(w.inactive_title_bar_text_color.is_some());
1430        assert!(w.title_bar_font.is_some());
1431        assert!(w.border.is_some());
1432    }
1433
1434    #[test]
1435    fn window_theme_default_is_empty() {
1436        assert!(WindowTheme::default().is_empty());
1437    }
1438
1439    // === DialogTheme tests ===
1440
1441    #[test]
1442    fn dialog_theme_button_order_works() {
1443        let d = DialogTheme {
1444            button_order: Some(DialogButtonOrder::PrimaryRight),
1445            min_width: Some(300.0),
1446            ..Default::default()
1447        };
1448        assert_eq!(d.button_order, Some(DialogButtonOrder::PrimaryRight));
1449        assert_eq!(d.min_width, Some(300.0));
1450        assert!(!d.is_empty());
1451    }
1452
1453    #[test]
1454    fn dialog_theme_button_order_toml_round_trip() {
1455        let d = DialogTheme {
1456            button_order: Some(DialogButtonOrder::PrimaryLeft),
1457            ..Default::default()
1458        };
1459        let toml_str = toml::to_string(&d).unwrap();
1460        let d2: DialogTheme = toml::from_str(&toml_str).unwrap();
1461        assert_eq!(d, d2);
1462    }
1463
1464    #[test]
1465    fn dialog_theme_default_is_empty() {
1466        assert!(DialogTheme::default().is_empty());
1467    }
1468
1469    // === SplitterTheme tests ===
1470
1471    #[test]
1472    fn splitter_theme_single_field_merge() {
1473        let mut base = SplitterTheme {
1474            divider_width: Some(4.0),
1475            ..Default::default()
1476        };
1477        let overlay = SplitterTheme {
1478            divider_width: Some(6.0),
1479            ..Default::default()
1480        };
1481        base.merge(&overlay);
1482        assert_eq!(base.divider_width, Some(6.0));
1483    }
1484
1485    #[test]
1486    fn splitter_theme_merge_none_preserves_base() {
1487        let mut base = SplitterTheme {
1488            divider_width: Some(4.0),
1489            ..Default::default()
1490        };
1491        let overlay = SplitterTheme::default();
1492        base.merge(&overlay);
1493        assert_eq!(base.divider_width, Some(4.0));
1494    }
1495
1496    #[test]
1497    fn splitter_theme_default_is_empty() {
1498        assert!(SplitterTheme::default().is_empty());
1499    }
1500
1501    #[test]
1502    fn splitter_theme_not_empty_when_set() {
1503        assert!(
1504            !SplitterTheme {
1505                divider_width: Some(4.0),
1506                ..Default::default()
1507            }
1508            .is_empty()
1509        );
1510    }
1511
1512    // === SeparatorTheme tests ===
1513
1514    #[test]
1515    fn separator_theme_single_field() {
1516        let s = SeparatorTheme {
1517            line_color: Some(Rgba::rgb(200, 200, 200)),
1518            ..Default::default()
1519        };
1520        assert!(!s.is_empty());
1521    }
1522
1523    // === All 25 widget theme defaults are empty ===
1524
1525    #[test]
1526    fn all_widget_theme_defaults_are_empty() {
1527        assert!(WindowTheme::default().is_empty());
1528        assert!(ButtonTheme::default().is_empty());
1529        assert!(InputTheme::default().is_empty());
1530        assert!(CheckboxTheme::default().is_empty());
1531        assert!(MenuTheme::default().is_empty());
1532        assert!(TooltipTheme::default().is_empty());
1533        assert!(ScrollbarTheme::default().is_empty());
1534        assert!(SliderTheme::default().is_empty());
1535        assert!(ProgressBarTheme::default().is_empty());
1536        assert!(TabTheme::default().is_empty());
1537        assert!(SidebarTheme::default().is_empty());
1538        assert!(ToolbarTheme::default().is_empty());
1539        assert!(StatusBarTheme::default().is_empty());
1540        assert!(ListTheme::default().is_empty());
1541        assert!(PopoverTheme::default().is_empty());
1542        assert!(SplitterTheme::default().is_empty());
1543        assert!(SeparatorTheme::default().is_empty());
1544        assert!(SwitchTheme::default().is_empty());
1545        assert!(DialogTheme::default().is_empty());
1546        assert!(SpinnerTheme::default().is_empty());
1547        assert!(ComboBoxTheme::default().is_empty());
1548        assert!(SegmentedControlTheme::default().is_empty());
1549        assert!(CardTheme::default().is_empty());
1550        assert!(ExpanderTheme::default().is_empty());
1551        assert!(LinkTheme::default().is_empty());
1552    }
1553
1554    // === Representative TOML round-trips ===
1555
1556    #[test]
1557    fn input_theme_toml_round_trip() {
1558        let t = InputTheme {
1559            background_color: Some(Rgba::rgb(255, 255, 255)),
1560            font: Some(FontSpec {
1561                family: Some("Noto Sans".into()),
1562                ..Default::default()
1563            }),
1564            border: Some(WidgetBorderSpec {
1565                color: Some(Rgba::rgb(180, 180, 180)),
1566                corner_radius: Some(4.0),
1567                ..Default::default()
1568            }),
1569            ..Default::default()
1570        };
1571        let toml_str = toml::to_string(&t).unwrap();
1572        let t2: InputTheme = toml::from_str(&toml_str).unwrap();
1573        assert_eq!(t, t2);
1574    }
1575
1576    #[test]
1577    fn switch_theme_toml_round_trip() {
1578        let s = SwitchTheme {
1579            checked_background: Some(Rgba::rgb(0, 120, 215)),
1580            track_width: Some(40.0),
1581            track_height: Some(20.0),
1582            thumb_diameter: Some(14.0),
1583            track_radius: Some(10.0),
1584            ..Default::default()
1585        };
1586        let toml_str = toml::to_string(&s).unwrap();
1587        let s2: SwitchTheme = toml::from_str(&toml_str).unwrap();
1588        assert_eq!(s, s2);
1589    }
1590
1591    #[test]
1592    fn card_theme_with_border() {
1593        let c = CardTheme {
1594            background_color: Some(Rgba::rgb(255, 255, 255)),
1595            border: Some(WidgetBorderSpec {
1596                corner_radius: Some(8.0),
1597                shadow_enabled: Some(true),
1598                ..Default::default()
1599            }),
1600        };
1601        assert!(!c.is_empty());
1602    }
1603
1604    #[test]
1605    fn link_theme_has_underline_enabled_bool_field() {
1606        let l = LinkTheme {
1607            visited_text_color: Some(Rgba::rgb(100, 0, 200)),
1608            underline_enabled: Some(true),
1609            ..Default::default()
1610        };
1611        assert!(!l.is_empty());
1612        assert_eq!(l.underline_enabled, Some(true));
1613    }
1614
1615    #[test]
1616    fn status_bar_theme_has_font_and_background() {
1617        let s = StatusBarTheme {
1618            background_color: Some(Rgba::rgb(240, 240, 240)),
1619            font: Some(FontSpec {
1620                size: Some(FontSize::Px(11.0)),
1621                ..Default::default()
1622            }),
1623            ..Default::default()
1624        };
1625        assert!(!s.is_empty());
1626    }
1627
1628    // === SC4: Dual optional_nested (font + border) test widget ===
1629
1630    // SC4: Verify derive handles dual optional_nested (font + border)
1631    /// Test widget with both font and border nested sub-structs.
1632    #[derive(
1633        Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize, ThemeWidget,
1634    )]
1635    #[serde_with::skip_serializing_none]
1636    #[serde(default)]
1637    #[theme_layer(skip_inventory)]
1638    pub struct DualNestedTestWidget {
1639        pub background: Option<Rgba>,
1640        pub min_height: Option<f32>,
1641        #[theme(nested, resolved_type = "ResolvedFontSpec")]
1642        pub font: Option<FontSpec>,
1643        #[theme(nested, resolved_type = "ResolvedWidgetBorder")]
1644        pub border: Option<WidgetBorderSpec>,
1645    }
1646
1647    #[test]
1648    fn dual_nested_default_is_empty() {
1649        assert!(DualNestedTestWidget::default().is_empty());
1650    }
1651
1652    #[test]
1653    fn dual_nested_field_names() {
1654        assert_eq!(DualNestedTestWidget::FIELD_NAMES.len(), 4);
1655        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"background"));
1656        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"min_height"));
1657        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"font"));
1658        assert!(DualNestedTestWidget::FIELD_NAMES.contains(&"border"));
1659    }
1660
1661    #[test]
1662    fn dual_nested_not_empty_when_font_set() {
1663        let w = DualNestedTestWidget {
1664            font: Some(FontSpec {
1665                family: Some("Inter".into()),
1666                ..Default::default()
1667            }),
1668            ..Default::default()
1669        };
1670        assert!(!w.is_empty());
1671    }
1672
1673    #[test]
1674    fn dual_nested_not_empty_when_border_set() {
1675        let w = DualNestedTestWidget {
1676            border: Some(WidgetBorderSpec {
1677                color: Some(Rgba::rgb(100, 100, 100)),
1678                ..Default::default()
1679            }),
1680            ..Default::default()
1681        };
1682        assert!(!w.is_empty());
1683    }
1684
1685    #[test]
1686    fn dual_nested_merge_both_nested() {
1687        let mut base = DualNestedTestWidget {
1688            font: Some(FontSpec {
1689                family: Some("Noto Sans".into()),
1690                ..Default::default()
1691            }),
1692            ..Default::default()
1693        };
1694        let overlay = DualNestedTestWidget {
1695            border: Some(WidgetBorderSpec {
1696                corner_radius: Some(4.0),
1697                ..Default::default()
1698            }),
1699            ..Default::default()
1700        };
1701        base.merge(&overlay);
1702        assert!(base.font.is_some());
1703        assert!(base.border.is_some());
1704        assert_eq!(
1705            base.font.as_ref().and_then(|f| f.family.as_deref()),
1706            Some("Noto Sans")
1707        );
1708        assert_eq!(
1709            base.border.as_ref().and_then(|b| b.corner_radius),
1710            Some(4.0)
1711        );
1712    }
1713
1714    #[test]
1715    fn dual_nested_merge_inner_font_fields() {
1716        let mut base = DualNestedTestWidget {
1717            font: Some(FontSpec {
1718                family: Some("Noto Sans".into()),
1719                ..Default::default()
1720            }),
1721            ..Default::default()
1722        };
1723        let overlay = DualNestedTestWidget {
1724            font: Some(FontSpec {
1725                size: Some(FontSize::Px(14.0)),
1726                ..Default::default()
1727            }),
1728            ..Default::default()
1729        };
1730        base.merge(&overlay);
1731        let font = base.font.as_ref().unwrap();
1732        assert_eq!(font.family.as_deref(), Some("Noto Sans")); // preserved
1733        assert_eq!(font.size, Some(FontSize::Px(14.0))); // overlay sets
1734    }
1735
1736    #[test]
1737    fn dual_nested_toml_round_trip() {
1738        let w = DualNestedTestWidget {
1739            background: Some(Rgba::rgb(240, 240, 240)),
1740            min_height: Some(32.0),
1741            font: Some(FontSpec {
1742                family: Some("Inter".into()),
1743                size: Some(FontSize::Px(14.0)),
1744                weight: Some(400),
1745                ..Default::default()
1746            }),
1747            border: Some(WidgetBorderSpec {
1748                color: Some(Rgba::rgb(180, 180, 180)),
1749                corner_radius: Some(4.0),
1750                line_width: Some(1.0),
1751                ..Default::default()
1752            }),
1753        };
1754        let toml_str = toml::to_string(&w).unwrap();
1755        let w2: DualNestedTestWidget = toml::from_str(&toml_str).unwrap();
1756        assert_eq!(w, w2);
1757    }
1758
1759    // === LayoutTheme tests ===
1760
1761    // === validate_widget() generation tests ===
1762
1763    #[test]
1764    fn button_validate_widget_extracts_all_fields() {
1765        let button = ButtonTheme {
1766            background_color: Some(Rgba::rgb(200, 200, 200)),
1767            primary_background: Some(Rgba::rgb(0, 120, 215)),
1768            primary_text_color: Some(Rgba::rgb(255, 255, 255)),
1769            min_width: Some(80.0),
1770            min_height: Some(32.0),
1771            icon_text_gap: Some(8.0),
1772            disabled_opacity: Some(0.4),
1773            hover_background: Some(Rgba::rgb(210, 210, 210)),
1774            hover_text_color: Some(Rgba::rgb(0, 0, 0)),
1775            active_text_color: Some(Rgba::rgb(0, 0, 0)),
1776            disabled_text_color: Some(Rgba::rgb(128, 128, 128)),
1777            active_background: Some(Rgba::rgb(180, 180, 180)),
1778            disabled_background: Some(Rgba::rgb(220, 220, 220)),
1779            checked_background: Some(Rgba::rgb(190, 190, 190)),
1780            checked_text_color: None,
1781            font: Some(FontSpec {
1782                family: Some("Inter".into()),
1783                size: Some(FontSize::Px(14.0)),
1784                weight: Some(400),
1785                style: Some(crate::model::font::FontStyle::Normal),
1786                color: Some(Rgba::rgb(0, 0, 0)),
1787            }),
1788            border: Some(WidgetBorderSpec {
1789                color: Some(Rgba::rgb(100, 100, 100)),
1790                corner_radius: Some(4.0),
1791                line_width: Some(1.0),
1792                shadow_enabled: Some(false),
1793                padding_top: Some(6.0),
1794                padding_right: Some(12.0),
1795                padding_bottom: Some(6.0),
1796                padding_left: Some(12.0),
1797            }),
1798        };
1799        let mut missing = Vec::new();
1800        let resolved = ResolvedButtonTheme::validate_widget(&button, "button", 96.0, &mut missing);
1801        assert!(missing.is_empty(), "unexpected missing: {missing:?}");
1802        assert_eq!(resolved.background_color, Rgba::rgb(200, 200, 200));
1803        assert_eq!(resolved.min_width, 80.0);
1804        assert_eq!(resolved.font.family.as_ref(), "Inter");
1805        assert_eq!(resolved.font.size, 14.0);
1806        assert_eq!(resolved.border.corner_radius, 4.0);
1807        // soft_option fields pass through as Option
1808        assert_eq!(resolved.active_background, Some(Rgba::rgb(180, 180, 180)));
1809        assert_eq!(resolved.disabled_background, Some(Rgba::rgb(220, 220, 220)));
1810        assert_eq!(resolved.checked_background, Some(Rgba::rgb(190, 190, 190)));
1811        assert_eq!(resolved.checked_text_color, None);
1812    }
1813
1814    #[test]
1815    fn button_validate_widget_records_missing_fields() {
1816        let button = ButtonTheme::default(); // all None
1817        let mut missing = Vec::new();
1818        let _ = ResolvedButtonTheme::validate_widget(&button, "button", 96.0, &mut missing);
1819        // option fields should be recorded as missing
1820        assert!(missing.contains(&"button.background_color".to_string()));
1821        assert!(missing.contains(&"button.min_width".to_string()));
1822        // font (optional_nested) should be recorded
1823        assert!(missing.contains(&"button.font".to_string()));
1824        // border (optional_nested) should be recorded
1825        assert!(missing.contains(&"button.border".to_string()));
1826        // soft_option fields should NOT be recorded as missing
1827        assert!(!missing.iter().any(|m| m.contains("active_background")));
1828        assert!(!missing.iter().any(|m| m.contains("disabled_background")));
1829    }
1830
1831    // === LayoutTheme tests ===
1832
1833    #[test]
1834    fn layout_theme_default_is_empty() {
1835        assert!(LayoutTheme::default().is_empty());
1836    }
1837
1838    #[test]
1839    fn layout_theme_not_empty_when_widget_gap_set() {
1840        let l = LayoutTheme {
1841            widget_gap: Some(8.0),
1842            ..Default::default()
1843        };
1844        assert!(!l.is_empty());
1845    }
1846
1847    #[test]
1848    fn layout_theme_field_names() {
1849        assert_eq!(LayoutTheme::FIELD_NAMES.len(), 4);
1850        assert!(LayoutTheme::FIELD_NAMES.contains(&"widget_gap_px"));
1851        assert!(LayoutTheme::FIELD_NAMES.contains(&"container_margin_px"));
1852        assert!(LayoutTheme::FIELD_NAMES.contains(&"window_margin_px"));
1853        assert!(LayoutTheme::FIELD_NAMES.contains(&"section_gap_px"));
1854    }
1855
1856    #[test]
1857    fn layout_theme_toml_round_trip() {
1858        let l = LayoutTheme {
1859            widget_gap: Some(8.0),
1860            container_margin: Some(12.0),
1861            window_margin: Some(16.0),
1862            section_gap: Some(24.0),
1863        };
1864        let toml_str = toml::to_string(&l).unwrap();
1865        let l2: LayoutTheme = toml::from_str(&toml_str).unwrap();
1866        assert_eq!(l, l2);
1867    }
1868
1869    #[test]
1870    fn layout_theme_merge() {
1871        let mut base = LayoutTheme {
1872            widget_gap: Some(6.0),
1873            container_margin: Some(10.0),
1874            ..Default::default()
1875        };
1876        let overlay = LayoutTheme {
1877            widget_gap: Some(8.0),
1878            section_gap: Some(24.0),
1879            ..Default::default()
1880        };
1881        base.merge(&overlay);
1882        // overlay widget_gap replaces base
1883        assert_eq!(base.widget_gap, Some(8.0));
1884        // base container_margin preserved
1885        assert_eq!(base.container_margin, Some(10.0));
1886        // overlay section_gap added
1887        assert_eq!(base.section_gap, Some(24.0));
1888        // window_margin stays None
1889        assert!(base.window_margin.is_none());
1890    }
1891}