rust_widgets 2.8.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

use crate::compat::BTreeMap;
use crate::core::{Color, Font};
#[cfg(not(alloc_frugal))]
use serde::{Deserialize, Serialize};

use crate::style::{EasingFunction, Shadow};

/// High-level theme definition used by runtime style resolution.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct Theme {
    /// Theme unique name.
    pub name: String,
    /// Which appearance this theme provides.
    ///
    /// Recorded on the theme itself rather than inferred from the background
    /// colour: a caller switching between a light and a dark theme needs to know
    /// which is which before it has resolved any colour, and an app with a custom
    /// palette cannot be classified by luminance.
    ///
    /// Named `Appearance`, not `ThemeMode`: [`crate::style::ThemeMode`] already
    /// means the *user's preference* (including `Auto`), and both modules are
    /// glob-re-exported through `crate::style`, so two types sharing that name
    /// would collide at the re-export.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub appearance: AppearanceMode,
    /// Semantic color tokens.
    pub colors: Colors,
    /// Font tokens.
    pub fonts: Fonts,
    /// Spacing tokens.
    pub spacing: Spacing,
    /// Border/elevation tokens.
    pub borders: Borders,
    /// Whether this theme draws **flat** faces: no bevel, no elevation, no material.
    ///
    /// # Why this is one flag rather than a table of kind overrides
    ///
    /// The distinction BLUE24 §10A draws is between a *flat* style and a *dimensional* one, and
    /// it applies to **every** control: a theme that says "I am flat" means it about the card, the
    /// button, the dialog and the widget nobody has written yet. Expressing it as a table of
    /// `"<kind>": { "bevel": null }` entries means the next control added is **not** flat until
    /// someone remembers to add its key — measured, the first attempt at that table flattened 22
    /// kinds and left the fallback role casting shadows again.
    ///
    /// So the flat style is a property of the *preset*, read once where the role default is
    /// resolved, and the per-kind/per-state `overrides.styles` keys remain available for a theme
    /// that wants to make a **specific** control differ from its preset — which is a different, and
    /// still useful, statement.
    ///
    /// Default `false`: a theme built by a caller who does not think about surfaces keeps the role
    /// table's character, which is what every theme written before this field expects.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub flat_surfaces: bool,
    /// Class-level style overrides applied after base resolution.
    pub overrides: ThemeOverrides,
    /// Animation timing tokens.
    ///
    /// # Why motion is themed at all
    ///
    /// Durations are a design decision, not a constant: a platform that respects a user's
    /// reduced-motion preference shortens them, a game-like skin lengthens them, and a test harness
    /// sets them to zero to make an animated control reach its end state deterministically. Before
    /// this, `src/style/animation.rs` carried a complete engine whose durations could only be passed
    /// in per call site, so "how fast does this library feel" was not something a theme could state.
    ///
    /// The shared Material values are (`kThemeChangeDuration` 200 ms,
    /// `kRadialReactionDuration` 100 ms, the switch's 300 ms toggle), so an unstyled theme moves at
    /// the tempo a Material application is expected to.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub motion: Motion,
}

/// How long an interaction takes, and how it eases.
///
/// Values are milliseconds, matching [`AnimationConfig::duration`](crate::style::AnimationConfig).
/// Grouped by *role* rather than by control so a theme describes its rhythm once: a hover reaction
/// and a press ripple are both `fast`, and a control's own state transition is `normal` regardless
/// of which control it is.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(not(alloc_frugal), serde(default))]
pub struct Motion {
    /// A direct reaction to the pointer — a highlight, a ripple. Material's
    /// `kRadialReactionDuration` is 100 ms.
    pub fast: u32,
    /// A control's own state change — a toggle moving, a colour settling. Material's
    /// `kThemeChangeDuration` is 200 ms.
    pub normal: u32,
    /// A larger transition — a sheet opening, a panel expanding. Material's switch toggle is
    /// 300 ms, which is the longest any control interaction should take before it reads as slow.
    pub slow: u32,
    /// The easing applied to a state transition.
    ///
    /// `EaseOut` by default: an interaction should start immediately and settle, because a slow
    /// start reads as a missed input. This is the shape Material's standard curve has.
    pub easing: EasingFunction,
}

impl Default for Motion {
    fn default() -> Self {
        Self { fast: 100, normal: 200, slow: 300, easing: EasingFunction::EaseOut }
    }
}

/// Which appearance a theme provides.
///
/// Distinct from [`crate::style::ThemeMode`], which is the *user's* preference
/// (including "follow the system"). This enum records a fact about the theme
/// data; that one records an intent about which theme to choose. The two are
/// deliberately separate types rather than one, because they answer different
/// questions and merging them would force a theme file to claim a preference it
/// cannot know.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AppearanceMode {
    /// Light background, dark foreground.
    #[default]
    Light,
    /// Dark background, light foreground.
    Dark,
}

/// The role a widget plays in the theme's colour scheme.
///
/// Resolution used to be a `match` over thirteen hardcoded lowercase strings, so
/// a control whose name was not in that list got the generic `_` colours and
/// there was no way for a third-party widget to declare its role at all. A role
/// is a *small, closed* set of visual treatments (how many distinct colour
/// treatments does a widget library really have?), so it is an enum the caller
/// names, not a string the resolver guesses at.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WidgetRole {
    /// The generic surface: panels, windows, dialogs, and anything unclassified.
    #[default]
    Surface,
    /// A filled, brand-coloured call to action (buttons, toggles).
    Primary,
    /// Plain text on the surface (labels, headings).
    Text,
    /// An editable field's interior (line edits, text edits).
    Input,
    /// A value indicator drawn in the accent colour (sliders, progress bars).
    Accent,
    /// A selectable indicator's box (check boxes, radio buttons, switches).
    Choice,
    /// A destructive or error action.
    Danger,
}

impl WidgetRole {
    /// The role a widget kind plays, for the kinds the library ships.
    ///
    /// Kinds not named here resolve to [`WidgetRole::Surface`], which is the same
    /// treatment they received from the old `_` arm — so the classification is a
    /// superset of the previous behaviour, not a change in it.
    ///
    /// A name-based lookup rather than a `WidgetKind` match so the resolver keeps
    /// working for a kind the library has never heard of: the caller passes the
    /// kind's name, and an unknown name gets the honest default.
    pub fn for_kind_name(kind_name: &str) -> Self {
        let normalized: String = kind_name
            .chars()
            .filter(|c| !matches!(c, '_' | '-' | ' '))
            .flat_map(char::to_lowercase)
            .collect();
        match normalized.as_str() {
            // Filled, brand-coloured actions.
            "button" | "toggle" | "togglebutton" => Self::Primary,
            // Plain text on the surface.
            "label" | "floatinglabel" | "text" | "heading" | "breadcrumb" | "commandlink"
            | "lcdnumber" | "fontpreview" => Self::Text,
            // Editable field interiors.
            "lineedit"
            | "textedit"
            | "textareainput"
            | "textarea"
            | "spinbox"
            | "doublespinbox"
            | "combobox"
            | "editablecombobox"
            | "fontcombobox"
            | "autocompleteedit"
            | "maskededit"
            | "multiselectcombobox"
            | "searchbar"
            | "searchbox"
            | "taginput"
            | "codeeditor"
            | "richedit"
            | "dateedit"
            | "timeedit"
            | "datetimeedit"
            | "inplaceeditor"
            | "shortcuteditor"
            | "coloreditor" => Self::Input,
            // Value indicators in the accent colour.
            "slider" | "rangeslider" | "progressbar" | "progresscircle" | "activityindicator"
            | "meter" | "lcdprogress" | "sparkline" => Self::Accent,
            // Selectable indicators.
            "checkbox" | "radiobutton" | "switch" | "checklistbox" | "rating" | "stepper" => {
                Self::Choice
            }
            // Scrolling, selectable fields whose whole rectangle is the control.
            //
            // # Why these are `Input` rather than falling through to `Surface`
            //
            // They used to reach the `_` arm, which resolves to `theme.colors.background` —
            // the very colour a window paints. A list box, a combo box's list and a text area
            // were therefore filled with the window's own background, so their extents were
            // invisible: the frame rendered correctly and showed nothing where the control
            // was. The distinction a reader needs is "this rectangle is a field I can put the
            // cursor in" versus "this rectangle is bare surface", and only `Input` carries it.
            "listbox" | "listview" | "tableview" | "treeview" | "scrollarea" | "textbrowser"
            | "plaintextedit" => Self::Input,
            // A scrollbar's trough is a control surface with a movable thumb in it, so it
            // belongs with the fields above rather than with bare surface.
            //
            // It used to reach the `_` arm and resolve to `theme.colors.background` — the window
            // fill. That mattered twice over, because this control read the *same* field for its
            // thumb and its trough: with both resolving to the window colour, the thumb was
            // separated from its groove by nothing at all, and `scroll_bar.svg` carried two
            // byte-identical `rgba(18,18,18)` rectangles where a scrollbar should be.
            "scrollbar" | "scroll_bar" => Self::Input,
            // Destructive or error presentation.
            "errordialog" | "trash" | "deletebutton" => Self::Danger,
            _ => Self::Surface,
        }
    }
}

/// Semantic color palette tokens.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct Colors {
    /// Default background color.
    pub background: Color,
    /// Default foreground/text color.
    pub foreground: Color,
    /// Primary brand/action color.
    pub primary: Color,
    /// Secondary neutral color.
    pub secondary: Color,
    /// Accent color.
    pub accent: Color,
    /// Error state color.
    pub error: Color,
    /// Warning state color.
    pub warning: Color,
    /// Success state color.
    pub success: Color,
    /// Disabled-state color.
    pub disabled: Color,
    /// Informational state color.
    #[cfg_attr(not(alloc_frugal), serde(default = "default_info_color"))]
    pub info: Color,
    /// Hairline separators, borders and focus rings.
    ///
    /// # Why this is separate from `foreground`
    ///
    /// A divider and a focus ring are both "a thin line", but they are not the same
    /// line: a divider should recede while a focus ring must announce itself. Deriving
    /// both from `foreground` meant a focused control looked identical to a bordered
    /// one, which is the BLUE21 §七 B defect. Material 3 names the pair `outline` and
    /// `outlineVariant`; this is the stronger of the two.
    #[cfg_attr(not(alloc_frugal), serde(default = "default_outline_color"))]
    pub outline: Color,
    /// A weaker secondary separator, for nested or low-emphasis dividers.
    #[cfg_attr(not(alloc_frugal), serde(default = "default_outline_variant_color"))]
    pub outline_variant: Color,
    /// The dimming layer behind a modal surface.
    ///
    /// # Why a theme needs a scrim of its own
    ///
    /// A modal dialog dims what is behind it to focus attention on itself. That dimming
    /// used to be an inline translucent black, which does nothing on a dark theme —
    /// "dimming" a surface that is already near-black leaves it near-black, so a modal
    /// and a non-modal window looked the same (BLUE21 B23). A named role lets a dark
    /// theme lighten the scrim instead.
    #[cfg_attr(not(alloc_frugal), serde(default = "default_scrim_color"))]
    pub scrim: Color,
    /// The container colour for cards and panels sitting on `background`.
    #[cfg_attr(not(alloc_frugal), serde(default = "default_surface_container_color"))]
    pub surface_container: Color,
    /// A raised step above [`Colors::surface_container`].
    #[cfg_attr(not(alloc_frugal), serde(default = "default_surface_container_high_color"))]
    pub surface_container_high: Color,
    /// A surface whose text is [`Colors::on_inverse_surface`], for snack bars and
    /// tooltips that deliberately invert to stand out.
    #[cfg_attr(not(alloc_frugal), serde(default = "default_inverse_surface_color"))]
    pub inverse_surface: Color,
    /// The ink legible on [`Colors::inverse_surface`].
    #[cfg_attr(not(alloc_frugal), serde(default = "default_on_inverse_surface_color"))]
    pub on_inverse_surface: Color,
}

/// Default info color used for backward-compatible deserialization.
#[cfg(not(alloc_frugal))]
const fn default_info_color() -> Color {
    Color::INFO
}

// The defaults below exist for **deserialization** (rule: every new field must be
// optional in a theme file an older build wrote). Each one is a mid-grey chosen to be
// legible on either appearance, so a theme file that omits the role still resolves to
// something renderable rather than to `Color::default()`'s transparent black.

/// Default separator colour (`#79747E`, Material 3's light-theme `outline`).
#[cfg(not(alloc_frugal))]
const fn default_outline_color() -> Color {
    Color::rgb(121, 116, 126)
}

/// Default secondary separator colour (`#CAC4D0`, M3's light `outlineVariant`).
#[cfg(not(alloc_frugal))]
const fn default_outline_variant_color() -> Color {
    Color::rgb(202, 196, 208)
}

/// Default modal scrim: translucent black, Material's 32 % overlay.
#[cfg(not(alloc_frugal))]
const fn default_scrim_color() -> Color {
    Color::rgba(0, 0, 0, 82)
}

/// Default card/panel container colour (`#F3EDF7`).
#[cfg(not(alloc_frugal))]
const fn default_surface_container_color() -> Color {
    Color::rgb(243, 237, 247)
}

/// Default raised container colour (`#ECE6F0`), one step above
/// [`default_surface_container_color`].
#[cfg(not(alloc_frugal))]
const fn default_surface_container_high_color() -> Color {
    Color::rgb(236, 230, 240)
}

/// Default inverse surface (`#313033`), M3's dark surface used on snack bars.
#[cfg(not(alloc_frugal))]
const fn default_inverse_surface_color() -> Color {
    Color::rgb(49, 48, 51)
}

/// Default ink on the inverse surface (`#F4EFF4`, M3's `inverseOnSurface`).
#[cfg(not(alloc_frugal))]
const fn default_on_inverse_surface_color() -> Color {
    Color::rgb(244, 239, 244)
}

impl Default for Colors {
    /// A complete, renderable palette with every role populated.
    ///
    /// # Why this exists at all
    ///
    /// There was no `Default for Colors`, so the two places that needed one —
    /// `Default for Theme` and every test that wanted "a palette" — spelled out a
    /// struct literal with every field. Adding a role therefore broke an unbounded
    /// number of call sites, which is exactly what made a colour role expensive to
    /// add and is why the palette stayed at eleven roles. A `Default` makes adding one
    /// a local change: new field, one line here, one line in `Theme::default`.
    ///
    /// The ten original roles reproduce `Theme::default()`'s long-standing palette
    /// **exactly** (including `background = 240`), because every other preset and every
    /// test derives its expected colours from this theme. `Colors::default()` is a
    /// *base*, not a redesign: only the roles the palette never had are new values.
    fn default() -> Self {
        Self {
            background: Color::rgb(240, 240, 240),
            foreground: Color::rgb(0, 0, 0),
            primary: Color::rgb(33, 150, 243),
            secondary: Color::rgb(158, 158, 158),
            accent: Color::rgb(255, 152, 0),
            error: Color::rgb(244, 67, 54),
            warning: Color::rgb(255, 193, 7),
            success: Color::rgb(76, 175, 80),
            disabled: Color::rgb(200, 200, 200),
            info: Color::INFO,
            outline: Color::rgb(121, 116, 126),
            outline_variant: Color::rgb(202, 196, 208),
            scrim: Color::rgba(0, 0, 0, 82),
            surface_container: Color::rgb(243, 237, 247),
            surface_container_high: Color::rgb(236, 230, 240),
            inverse_surface: Color::rgb(49, 48, 51),
            on_inverse_surface: Color::rgb(244, 239, 244),
        }
    }
}

impl Color {
    /// Parses a hex color string (`"#RRGGBB"` or `"#RRGGBBAA"`) into a `Color`.
    ///
    /// # Errors
    /// Returns an error if the hex string is malformed or missing the `#` prefix.
    pub fn from_hex(hex: &str) -> Result<Self, String> {
        Self::parse_hex(hex).ok_or_else(|| format!("Invalid hex color string: '{hex}'"))
    }

    /// Serializes the color to `"#RRGGBBAA"` hex format.
    pub fn to_hex(&self) -> String {
        self.to_hex_rgba()
    }

    /// Returns a darkened variant of this color by reducing each RGB component
    /// by the given `factor` (clamped to `[0.0, 1.0]`).
    ///
    /// A factor of `0.0` leaves the color unchanged; `1.0` produces black.
    /// Alpha is preserved unchanged.
    pub fn dark_variant(&self, factor: f32) -> Self {
        let f = factor.clamp(0.0, 1.0);
        Self::rgba(
            (self.r as f32 * (1.0 - f)).round().clamp(0.0, 255.0) as u8,
            (self.g as f32 * (1.0 - f)).round().clamp(0.0, 255.0) as u8,
            (self.b as f32 * (1.0 - f)).round().clamp(0.0, 255.0) as u8,
            self.a,
        )
    }

    /// Returns a lightened variant of this color by increasing each RGB component
    /// toward 255 by the given `factor` (clamped to `[0.0, 1.0]`).
    ///
    /// A factor of `0.0` leaves the color unchanged; `1.0` produces white.
    /// Alpha is preserved unchanged.
    pub fn light_variant(&self, factor: f32) -> Self {
        let f = factor.clamp(0.0, 1.0);
        Self::rgba(
            (self.r as f32 + (255.0 - self.r as f32) * f).round().clamp(0.0, 255.0) as u8,
            (self.g as f32 + (255.0 - self.g as f32) * f).round().clamp(0.0, 255.0) as u8,
            (self.b as f32 + (255.0 - self.b as f32) * f).round().clamp(0.0, 255.0) as u8,
            self.a,
        )
    }
}

/// Font token set used by theme consumers.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct Fonts {
    /// Regular text font token.
    pub regular: Font,
    /// Bold text font token.
    pub bold: Font,
    /// Italic text font token.
    pub italic: Font,
    /// Monospace font token.
    pub monospace: Font,
    /// Caption / footnote font token (small, secondary text).
    #[cfg_attr(not(alloc_frugal), serde(default = "default_caption_font"))]
    pub caption: Font,
    /// Body text font token (default paragraph text).
    #[cfg_attr(not(alloc_frugal), serde(default = "default_body_font"))]
    pub body: Font,
    /// Title font token (section or widget titles).
    #[cfg_attr(not(alloc_frugal), serde(default = "default_title_font"))]
    pub title: Font,
    /// Headline font token (prominent section headings).
    #[cfg_attr(not(alloc_frugal), serde(default = "default_headline_font"))]
    pub headline: Font,
    /// Display font token (large, decorative text).
    #[cfg_attr(not(alloc_frugal), serde(default = "default_display_font"))]
    pub display: Font,
}

/// Default caption font: Arial 11px, regular.
#[cfg(not(alloc_frugal))]
fn default_caption_font() -> Font {
    Font::simple("Arial", 11.0)
}

/// Default body font: Arial 14px, regular.
#[cfg(not(alloc_frugal))]
fn default_body_font() -> Font {
    Font::simple("Arial", 14.0)
}

/// Default title font: Arial 16px, bold.
#[cfg(not(alloc_frugal))]
fn default_title_font() -> Font {
    Font::bold("Arial", 16.0)
}

/// Default headline font: Arial 20px, bold.
#[cfg(not(alloc_frugal))]
fn default_headline_font() -> Font {
    Font::bold("Arial", 20.0)
}

/// Default display font: Arial 28px, bold.
#[cfg(not(alloc_frugal))]
fn default_display_font() -> Font {
    Font::bold("Arial", 28.0)
}

/// Spacing scale tokens.
///
/// # Recommendation
/// For more granular spacing, consider adding additional levels such as:
/// - `extra_small: u32` — 2px for tight spacing
/// - `huge: u32` — 48px for generous layout gaps
/// - `massive: u32` — 64px for section separators
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct Spacing {
    /// Small spacing unit.
    pub small: u32,
    /// Medium spacing unit.
    pub medium: u32,
    /// Large spacing unit.
    pub large: u32,
    /// Extra-large spacing unit.
    pub extra_large: u32,
}

/// Border and elevation behavior tokens.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct Borders {
    /// Default border width.
    pub width: u32,
    /// Default corner radius.
    pub radius: u32,
    /// Whether drop shadows are enabled.
    pub shadow: bool,
}

impl Theme {
    /// The drop shadow a face at `elevation` casts under this theme, or `None`.
    ///
    /// # Why this is a method here and not a serialised field
    ///
    /// A shadow is **derived** from two things the theme already states — whether this theme
    /// uses shadows at all ([`Borders::shadow`]) and how far off the page the face sits — so
    /// materialising it into the schema would be a second spelling of the same fact, and the
    /// two would drift (principle #101). Keeping it a lookup also means `themes/*.json` needs
    /// no new key, so an existing theme file keeps loading unchanged and this channel ships
    /// without a fixture regeneration.
    ///
    /// # Why the levels are not all the same shadow
    ///
    /// This is the whole point of the method. Before it, `role_base_style` built one literal
    /// shadow and gave it to every control, so a floating toast and a flush list row were
    /// painted at the same height and a theme could not say "cards are level 1". Reading the
    /// shadow through a *level* is what makes that expressible, and the ladder itself lives in
    /// [`render::surface::default_shadow`] so the relationship between levels is stated once.
    ///
    /// `tint` is the shadow's hue (normally black); each level applies its own alpha to it, so a
    /// theme states the hue once and the levels own their opacity.
    ///
    /// A theme that has shadows switched off gets `None` for every level, which is the honest
    /// answer rather than a zero-alpha shadow.
    pub fn elevation(&self, elevation: crate::render::Elevation, tint: Color) -> Option<Shadow> {
        if !self.borders.shadow {
            return None;
        }
        crate::render::default_shadow(elevation).map(|level| Shadow {
            x: 0,
            y: level.y,
            blur: level.blur,
            color: tint.with_alpha(level.alpha),
        })
    }
}

/// Style override map used for class-level theme customization.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone)]
pub struct ThemeOverrides {
    /// Overrides keyed by style/class name.
    ///
    /// # Why this is a `BTreeMap` and not a `HashMap`
    ///
    /// These entries are **serialised into the theme fixtures** (`themes/*.json`), and a
    /// `HashMap` iterates in an unspecified order. With an empty override table that never
    /// mattered; once the presets carried 26 state keys, a fresh generation produced a
    /// different key order on every run and `check_theme_fixtures.sh` -- which regenerates
    /// and compares -- failed intermittently for a reason that had nothing to do with the
    /// theme's content. A sorted map makes the file deterministic, so a diff means a real
    /// change.
    pub styles: BTreeMap<String, ThemeStyleToken>,
}

/// Optional style tokens used to override resolved widget styles.
///
/// The `Option`-valued fields are what make an override *partial*: a field left
/// as `None` keeps the value base resolution produced, so a theme can adjust one
/// property of one role without restating the rest.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ThemeStyleToken {
    /// Optional background override.
    pub background: Option<Color>,
    /// Optional foreground/text override.
    pub foreground: Option<Color>,
    /// Optional border color override.
    pub border: Option<Color>,
    /// Optional border width override.
    pub border_width: Option<u32>,
    /// Optional corner radius override.
    pub radius: Option<u32>,
    /// Optional font override, applied to the resolved style's `font`.
    ///
    /// Without this the theme's nine font tokens were unreachable from
    /// resolution: `resolve_style` never read `theme.fonts`, so every control
    /// kept the font its constructor chose.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub font: Option<Font>,
    /// Optional opacity override (clamped to `[0.0, 1.0]` on application).
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub opacity: Option<f32>,
    /// Optional drop-shadow override.
    ///
    /// A three-way choice, not `Option<Option<ShadowToken>>`: serde's `Option`
    /// deserializer maps a JSON `null` to `None` at the outer level, so the nested
    /// form can never distinguish "clear the shadow" from "do not mention it" —
    /// `Some(None)` is unreachable from a file, and the intent was silently lost.
    /// A named enum makes all three cases representable.
    ///
    /// Carried as a serde-friendly record rather than [`Shadow`], which is a
    /// render-layer type with no serialisation contract of its own. Keeping the
    /// theme schema free of render types also means a theme file cannot depend on
    /// how the renderer happens to represent a shadow today.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub shadow: ShadowOverride,
    /// Optional minimum touch-target override, as `[width, height]` in logical
    /// pixels. A two-element array rather than `core::Size` for the same reason as
    /// `shadow`: the theme schema stays on primitives.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub touch_target: Option<[u32; 2]>,

    // ── The four surface dimensions (BLUE24 §10A.4) ──
    //
    // Each is an optional override of one orthogonal dimension of the face this control is
    // drawn as. They reach a control through the same `overrides.styles` channel every other
    // key here uses, at both the `<kind>` / `<class>` layer and the `<kind>:<state>` layer —
    // so `"button:pressed": { "bevel": "inset" }` expresses the press feedback every 3D
    // button has, without a new theme layer and without a line of control code.
    /// Optional elevation override: which layer the face sits on.
    ///
    /// A **named** level (`"flat"`, `"level1"` …) rather than a number, so a theme file speaks
    /// the same word the role table does and an unrecognised word is a load error rather than a
    /// silent fallback (BLUE24 §10A.6 criterion 7).
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub elevation: Option<SurfaceElevationToken>,
    /// Optional bevel override: which way the face's edge is lit.
    ///
    /// Three-way like [`ShadowOverride`] and for the same reason: "inset a face the role
    /// raises", "flatten a face the role insets", and "say nothing" are three intents, and
    /// `Option` can only express two.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub bevel: BevelOverride,
    /// Optional material override: an opaque face or tinted glass.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub material: Option<SurfaceMaterialToken>,
    /// Optional edge override: whether the face draws a stroke, defers to its shadow, or has
    /// no visible edge.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub hairline: Option<SurfaceHairlineToken>,
}

/// A surface elevation as a theme file spells it.
///
/// # Why a newtype and not the render type directly
///
/// The theme schema deliberately stays on its own spellings (the rule [`ShadowOverride`]
/// follows), and [`crate::render::surface::Elevation`] has no serialisation contract. The
/// newtype is also where an unknown token can be **rejected**: a theme that writes
/// `"elevation": "high"` gets an error naming the word, not a face that silently sits on the
/// page (BLUE24 §10A.6 criterion 7).
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(alloc_frugal), serde(try_from = "String", into = "String"))]
pub struct SurfaceElevationToken(pub crate::render::surface::Elevation);

/// A surface material as a theme file spells it. See [`SurfaceElevationToken`].
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(alloc_frugal), serde(try_from = "String", into = "String"))]
pub struct SurfaceMaterialToken(pub crate::render::surface::Material);

/// Which edge a face draws, as a theme file spells it. See [`SurfaceElevationToken`].
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(alloc_frugal), serde(try_from = "String", into = "String"))]
pub struct SurfaceHairlineToken(pub crate::render::surface::Hairline);

/// The message an unrecognised surface token produces.
///
/// One spelling for all of them, so a reader who mistypes any gets the same shape of message:
/// the key, the offending word, and the words that would have worked.
#[cfg(not(alloc_frugal))]
pub(crate) fn unknown_surface_token(key: &str, value: &str) -> String {
    let accepted = match key {
        "elevation" => "flat, level1, level2, level3, level4",
        "material" => "solid, translucent",
        "hairline" => "outline, shadow, none",
        "bevel" => "raised, inset, none, inherit",
        _ => "see the surface-style documentation",
    };
    format!("unknown {key} token {value:?} (accepted: {accepted})")
}

#[cfg(not(alloc_frugal))]
impl TryFrom<String> for SurfaceElevationToken {
    type Error = String;
    fn try_from(value: String) -> Result<Self, Self::Error> {
        crate::render::surface::Elevation::parse(&value)
            .map(Self)
            .ok_or_else(|| unknown_surface_token("elevation", &value))
    }
}

#[cfg(not(alloc_frugal))]
impl TryFrom<String> for SurfaceMaterialToken {
    type Error = String;
    fn try_from(value: String) -> Result<Self, Self::Error> {
        crate::render::surface::Material::parse(&value)
            .map(Self)
            .ok_or_else(|| unknown_surface_token("material", &value))
    }
}

#[cfg(not(alloc_frugal))]
impl TryFrom<String> for SurfaceHairlineToken {
    type Error = String;
    fn try_from(value: String) -> Result<Self, Self::Error> {
        crate::render::surface::Hairline::parse(&value)
            .map(Self)
            .ok_or_else(|| unknown_surface_token("hairline", &value))
    }
}

// The round trip a theme file's `"keep it"` edit takes: reading a stored theme and writing it
// back must not change the token. Falling back to a default here would make an export lossy.
#[cfg(not(alloc_frugal))]
impl From<SurfaceElevationToken> for String {
    fn from(value: SurfaceElevationToken) -> Self {
        value.0.as_str().to_string()
    }
}

#[cfg(not(alloc_frugal))]
impl From<SurfaceMaterialToken> for String {
    fn from(value: SurfaceMaterialToken) -> Self {
        value.0.as_str().to_string()
    }
}

#[cfg(not(alloc_frugal))]
impl From<SurfaceHairlineToken> for String {
    fn from(value: SurfaceHairlineToken) -> Self {
        value.0.as_str().to_string()
    }
}

/// What a [`ThemeStyleToken`] says about a face's bevel.
///
/// # Why three cases rather than `Option<BevelDirection>`
///
/// Because there are three intents: **set** a direction (which may differ from the role
/// default — a theme that flattens buttons still wants a pressed field to read as a well),
/// **remove** the bevel the role default supplied (the flat-design case), and **inherit**.
/// `Option<BevelDirection>` can only say two of them (`Some` sets, `None` is ambiguous between
/// remove and inherit), which is the identical defect [`ShadowOverride`] fixes.
///
/// A theme file spells it `"raised"`, `"inset"`, `"none"` or `"inherit"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BevelOverride {
    /// Leave the bevel as the base resolution left it.
    #[default]
    Inherit,
    /// Remove the bevel, leaving a flat face.
    None,
    /// Bevel the face in this direction.
    Set(crate::render::BevelDirection),
}

#[cfg(not(alloc_frugal))]
impl<'de> serde::Deserialize<'de> for BevelOverride {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        use serde::de::Error as _;
        let token = String::deserialize(deserializer)?;
        match token.as_str() {
            "inherit" => Ok(Self::Inherit),
            "none" | "flat" => Ok(Self::None),
            other => crate::render::BevelDirection::parse(other)
                .map(Self::Set)
                // `"sunken"` is a plausible mistake for `"inset"`, and degrading it to "no
                // bevel" would make the face look flat while the author believes it is bevelled
                // (BLUE24 §10A.6 criterion 7).
                .ok_or_else(|| {
                    D::Error::custom(crate::theme::types::unknown_surface_token("bevel", other))
                }),
        }
    }
}

#[cfg(not(alloc_frugal))]
impl serde::Serialize for BevelOverride {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let token = match self {
            Self::Inherit => "inherit",
            Self::None => "none",
            Self::Set(direction) => direction.as_str(),
        };
        serializer.serialize_str(token)
    }
}

impl BevelOverride {
    /// Applies this override to a resolved bevel, or `None` when it should be removed.
    pub fn apply(
        self,
        current: Option<crate::render::BevelSpec>,
    ) -> Option<crate::render::BevelSpec> {
        match self {
            Self::Inherit => current,
            Self::None => None,
            Self::Set(direction) => Some(crate::render::BevelSpec::new(direction)),
        }
    }
}

/// What a [`ThemeStyleToken`] says about a widget's drop shadow.
///
/// Three cases, because there are three distinct intents and collapsing any two of
/// them loses information: a theme may want to set a shadow, remove the shadow the
/// base resolution supplied, or say nothing and inherit.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ShadowOverride {
    /// Leave the shadow as the base resolution left it.
    #[default]
    Inherit,
    /// Remove the shadow, leaving a flat surface.
    None,
    /// Replace the shadow with this one.
    Set(ShadowToken),
}

impl ShadowOverride {
    /// Applies this override to a resolved shadow value.
    ///
    /// Takes and returns the `Option<Shadow>` a `WidgetStyle` holds, so the caller
    /// does not have to re-derive the three-way mapping.
    pub fn apply(self, current: Option<Shadow>) -> Option<Shadow> {
        match self {
            Self::Inherit => current,
            Self::None => None,
            Self::Set(token) => Some(token.into()),
        }
    }
}

/// A drop-shadow described with primitives, for theme files.
///
/// Converts to the render-layer [`Shadow`] at application time, which is where
/// the two representations meet.
#[cfg_attr(not(alloc_frugal), derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShadowToken {
    /// Horizontal offset in logical pixels.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub x: i32,
    /// Vertical offset in logical pixels.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub y: i32,
    /// Blur radius in logical pixels.
    #[cfg_attr(not(alloc_frugal), serde(default))]
    pub blur: u32,
    /// Shadow colour.
    pub color: Color,
}

impl From<ShadowToken> for Shadow {
    fn from(token: ShadowToken) -> Self {
        Self { x: token.x, y: token.y, blur: token.blur, color: token.color }
    }
}