guido 0.12.0

A reactive Rust GUI library using wgpu for Wayland layer shell widgets
Documentation
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
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
//! How text looks — declared on the widget that draws the glyphs.
//!
//! [`Text`](crate::widgets::Text) and [`TextInput`](crate::widgets::TextInput)
//! are the two widgets that put glyphs on the screen, so they are the two that
//! say how those glyphs look. A container draws a box and says nothing about
//! what is written inside it.
//!
//! ```no_run
//! # use guido::prelude::*;
//! # struct Theme { text: Color }
//! # let theme = Theme { text: Color::WHITE };
//! text("Hello").font_size(16.0).color(theme.text).bold();
//! ```
//!
//! # A style is a partial record
//!
//! Every field is an `Option`, so a declaration says only what it means to
//! say. What fills the rest is the widget's own default — white, 14 logical
//! pixels, the registered family, normal weight, the font's own line height
//! and letter spacing — not a neighbouring declaration: nothing is inherited
//! from anywhere.
//!
//! The partiality earns its keep on state overrides, which *are* merged:
//! `when_hovered(|s| s.color(..))` changes the colour of a hovered label and
//! leaves its metrics alone. Every property resolves on its own, from the
//! nearest declaration that says anything about it.
//!
//! # The same style, many times
//!
//! Write a function. It keeps the declaration next to the widget that draws
//! it, costs no wrapper node, and gives the style a name:
//!
//! ```no_run
//! # use guido::prelude::*;
//! # let weak = Color::rgb(0.6, 0.6, 0.6);
//! let label = move |s: &str| text(s).color(weak).font_size(12.0);
//! container().children([label("one"), label("two"), label("three")]);
//! ```
//!
//! # A state that reaches the glyphs
//!
//! The pointer is over the box and the colour belongs to the glyphs. Declare
//! each where it happens and let [`control()`](crate::widgets::Container::control)
//! join them: a leaf resolves its own states from the nearest control above it.
//!
//! ```no_run
//! # use guido::prelude::*;
//! # let weak = Color::rgb(0.6, 0.6, 0.6);
//! # let strong = Color::WHITE;
//! container().control().when_hovered(|s| s.lighter(0.1))
//!     .child(text("Label").color(weak).when_hovered(|s| s.color(strong)));
//! ```

use smallvec::SmallVec;

use crate::clock::FrameInstant;
use crate::finite::{FiniteOr, first_finite_override};
use crate::jobs::RequiredJob;
use crate::reactive::{IntoSignal, Prop};
use crate::tree::WidgetId;
use crate::widgets::container::AnimationState;

use super::font::{FontFamily, FontWeight, LineHeight};
use super::widget::Color;

/// A contour drawn around the glyphs.
///
/// The legibility fix for text over an image, where no single colour works
/// against both the light and dark parts of the picture.
///
/// It is drawn *under* the fill, like SVG's `paint-order: stroke fill` and
/// unlike a naive implementation: painted over, a stroke eats half the weight
/// of every stem and the text reads as thinner rather than outlined.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextStroke {
    /// Half-width of the contour, in logical pixels — how far the stroke
    /// reaches outwards from the glyph edge.
    pub width: f32,
    pub color: Color,
}

impl TextStroke {
    pub fn new(width: f32, color: Color) -> Self {
        Self { width, color }
    }

    /// The offsets to draw the glyphs at, in logical pixels.
    ///
    /// A ring of copies around the original. This is the classic approximation
    /// and it costs nothing new: glyphon keys its atlas on glyph, size and
    /// weight, and takes colour per draw, so the extra copies re-use the
    /// rasterization already there and cost fill rate only.
    ///
    /// Its ceiling is the corners, which scallop once the gaps between samples
    /// exceed a pixel — so the tap count grows with the width instead of being
    /// fixed at the usual eight. Past a few pixels the honest fix is a dilate
    /// on an offscreen mask, not more taps.
    ///
    /// That fix exists for one case: a text with a
    /// [`backdrop_blur`](crate::widgets::Text::backdrop_blur) has a coverage
    /// mask already, and takes its stroke as a contour dilated from it — which
    /// it must, since copies under the fill would fill the glass as well as
    /// ring it.
    pub(crate) fn samples(&self) -> SmallVec<[(f32, f32); 24]> {
        let taps = (self.width * 6.0).clamp(8.0, 24.0) as usize;
        (0..taps)
            .map(|i| {
                let angle = std::f32::consts::TAU * i as f32 / taps as f32;
                (self.width * angle.cos(), self.width * angle.sin())
            })
            .collect()
    }
}

/// How far apart neighbouring shadow copies may land, in logical pixels.
///
/// Wider than this and the copies stop blending into one halo — see
/// [`TextShadow::samples`].
const SAMPLE_SPACING: f32 = 2.0;

/// Roughly the most copies one shadow may cost. The spacing is derived from it,
/// so a larger radius spreads the same budget thinner instead of costing more —
/// the realised count lands about a third above this, since each ring rounds its
/// tap count up and none goes below eight.
const SAMPLE_BUDGET: usize = 128;

/// A soft shadow cast by the glyphs, as CSS `text-shadow`.
///
/// Usually the better of the two for legibility over a photograph: it darkens
/// the neighbourhood the glyph sits in rather than only its edge, which is
/// what actually separates the text from a busy background.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextShadow {
    /// Offset in logical pixels, positive being right and down.
    pub offset: (f32, f32),
    /// How far the shadow spreads past the glyphs.
    pub blur: f32,
    pub color: Color,
}

impl TextShadow {
    pub fn new(offset_x: f32, offset_y: f32, blur: f32, color: Color) -> Self {
        Self {
            offset: (offset_x, offset_y),
            blur,
            color,
        }
    }

    /// The offsets and colours to draw the glyphs at, back to front.
    ///
    /// Same trick as the stroke, applied to a *disc* rather than a couple of
    /// rings: rings every [`SAMPLE_SPACING`] out to the radius, each with enough
    /// taps that neighbouring copies land that far apart along it too. Alpha
    /// falls off with distance, and the copies composite with ordinary
    /// source-over blending, so they do not sum to a gaussian — they saturate
    /// towards the centre, which is the shape a shadow wants anyway.
    ///
    /// The spacing is the whole game. Two rings of twelve taps — what this used
    /// to be — leaves five-pixel gaps at blur 10, and then the copies stop
    /// reading as one halo: a glyph's square features, a colon's dots or the
    /// stem of a 4, come out as a mosaic of separate rectangles. Round glyphs
    /// hide it, which is why it survived review. Filling the disc costs about
    /// four times the draws (fill rate only — every copy re-uses the same glyph
    /// rasters, only the position differs), and past [`SAMPLE_BUDGET`] the
    /// spacing widens rather than the count growing without bound, so a very
    /// large radius degrades towards the old look instead of costing hundreds of
    /// draws. That ceiling is where the honest fix takes over: a dilate and blur
    /// on an offscreen mask, not more taps.
    pub(crate) fn samples(&self) -> SmallVec<[(f32, f32, Color); 32]> {
        let (ox, oy) = self.offset;
        let mut out = SmallVec::new();

        if self.blur <= 0.0 {
            out.push((ox, oy, self.color));
            return out;
        }

        // A disc of radius r at this spacing holds about πr²/spacing² samples,
        // so this is the spacing that fits the budget for the radius asked for.
        let spacing =
            SAMPLE_SPACING.max(self.blur * (std::f32::consts::PI / SAMPLE_BUDGET as f32).sqrt());
        let rings = (self.blur / spacing).ceil().max(1.0);

        // Outermost first: each copy paints over the ones before it, so the
        // faint wide ones have to go down before the core.
        for ring in (1..=rings as usize).rev() {
            let radius = self.blur * ring as f32 / rings;
            let taps = ((std::f32::consts::TAU * radius / spacing).ceil() as usize).max(8);
            let color = self.color.with_alpha(self.ring_alpha(radius, rings));
            for i in 0..taps {
                let angle = std::f32::consts::TAU * i as f32 / taps as f32;
                out.push((ox + radius * angle.cos(), oy + radius * angle.sin(), color));
            }
        }
        // The core keeps the colour as asked, unscaled: it sits under the fill,
        // so it is not what the eye reads as the shadow's strength, and dimming
        // it only made a tight blur weaker than the same shadow with none.
        out.push((ox, oy, self.color));
        out
    }

    /// How opaque one copy on the ring at `radius` is.
    ///
    /// A gaussian in the distance, divided by how many rings there are to stack:
    /// without that, a wide blur — many more rings, all overlapping — would come
    /// out as a slab where a tight one is a halo.
    fn ring_alpha(&self, radius: f32, rings: f32) -> f32 {
        const PEAK: f32 = 0.6;
        let t = radius / self.blur;
        (self.color.a * PEAK / rings.sqrt() * (-2.0 * t * t).exp()).min(self.color.a)
    }
}

/// The size a text draws at when nothing declares one, in logical pixels.
///
/// Beside the resolver that hands it out, which is also what a declaration
/// nobody can compute falls back to.
pub(crate) const DEFAULT_FONT_SIZE: f32 = 14.0;

/// What a widget that draws glyphs declares about them, or what one of its state
/// overrides supplies.
///
/// Every field is optional and resolved independently: a state override that
/// sets only `color` leaves the metrics the widget declared alone. Properties
/// nothing declares fall back to [`Text`](crate::widgets::Text)'s defaults —
/// white, 14 logical pixels, the registered default family, normal weight, the
/// font's own line height.
#[derive(Clone, Copy, Default, PartialEq)]
pub struct TextStyle {
    /// Colour of the glyphs.
    pub color: Prop<Color>,
    /// Font size in logical pixels.
    pub font_size: Prop<f32>,
    /// Font family.
    pub font_family: Prop<FontFamily>,
    /// Font weight on the CSS 100-900 scale.
    pub font_weight: Prop<FontWeight>,
    /// How tall each line is.
    pub line_height: Prop<LineHeight>,
    /// Extra advance after every glyph, in logical pixels — see
    /// [`letter_spacing`](TextStyle::letter_spacing).
    pub letter_spacing: Prop<f32>,
    /// Contour drawn around the glyphs, under the fill.
    pub stroke: Prop<TextStroke>,
    /// Soft shadow cast by the glyphs.
    pub shadow: Prop<TextShadow>,
}

/// What a widget's text declarations come to: its own, and the active state
/// overrides that outrank it.
///
/// The two are kept apart rather than folded into a winner, which is what lets an
/// override nobody can compute fall through to the declaration it displaced —
/// a fold would have dropped that declaration before the finite check ever ran.
/// Container resolves its own properties in these two steps, and
/// [`first_finite_override`] carries the rule they share.
///
/// Only the three numeric properties have the distinction. A family and a weight
/// cannot fail to be numbers, so the nearest declaration is the whole answer for
/// them. A stroke and a shadow *can* — they are made of numbers — and they keep
/// the nearest declaration anyway, because no `AllFinite` impl reaches them: that
/// is a gap rather than a reason, and neither has an animation to poison, so a bad
/// number is gone from them the frame the signal recovers.
///
/// **A value is read when its property is asked for, not while walking.** The
/// walk runs during layout and again during paint, and a read subscribes whichever
/// pass it lands in — so testing a candidate while walking would subscribe the
/// *measurement* to a colour, and a theme change would then reflow every label
/// that declares a hover colour, since `Text` is not a relayout boundary. Asking
/// where the property is wanted puts each read in the pass that owns it: the size
/// while measuring, the colour while painting.
#[derive(Default)]
pub(crate) struct ResolvedTextStyle {
    /// Active overrides, nearest first.
    color_overrides: SmallVec<[Prop<Color>; 3]>,
    font_size_overrides: SmallVec<[Prop<f32>; 3]>,
    letter_spacing_overrides: SmallVec<[Prop<f32>; 3]>,
    /// The widget's own declaration, which every override falls through to and
    /// where the door reports.
    color: Prop<Color>,
    font_size: Prop<f32>,
    letter_spacing: Prop<f32>,
    font_family: Prop<FontFamily>,
    font_weight: Prop<FontWeight>,
    line_height: Prop<LineHeight>,
    stroke: Prop<TextStroke>,
    shadow: Prop<TextShadow>,
}

impl ResolvedTextStyle {
    /// Add an active override, further out than every one already added.
    pub(crate) fn push_override(&mut self, style: &TextStyle) {
        if style.color.is_set() {
            self.color_overrides.push(style.color);
        }
        if style.font_size.is_set() {
            self.font_size_overrides.push(style.font_size);
        }
        if style.letter_spacing.is_set() {
            self.letter_spacing_overrides.push(style.letter_spacing);
        }
        self.take_unset(style);
    }

    /// Add the widget's own declaration, which is the innermost there is.
    pub(crate) fn push_own(&mut self, style: &TextStyle) {
        self.color = style.color;
        self.font_size = style.font_size;
        self.letter_spacing = style.letter_spacing;
        self.take_unset(style);
    }

    /// The five that resolve to one declaration: nearest wins, so whatever is
    /// already set was found first.
    fn take_unset(&mut self, style: &TextStyle) {
        self.font_family = self.font_family.or(style.font_family);
        self.font_weight = self.font_weight.or(style.font_weight);
        self.line_height = self.line_height.or(style.line_height);
        self.stroke = self.stroke.or(style.stroke);
        self.shadow = self.shadow.or(style.shadow);
    }

    /// The colour to draw the glyphs in.
    pub(crate) fn color(&self, id: WidgetId) -> Color {
        let base = self.color.get_finite_or(Color::WHITE, id, "color");
        first_finite_override(&self.color_overrides, base)
    }

    /// The size to measure and draw the glyphs at, in logical pixels.
    pub(crate) fn font_size(&self, id: WidgetId) -> f32 {
        let base = self
            .font_size
            .get_finite_or(DEFAULT_FONT_SIZE, id, "font_size");
        first_finite_override(&self.font_size_overrides, base)
    }

    /// The extra advance after every glyph, in logical pixels. Zero where
    /// nothing declares one, and where nothing declared is a number.
    pub(crate) fn letter_spacing(&self, id: WidgetId) -> f32 {
        let base = self.letter_spacing.get_finite_or(0.0, id, "letter_spacing");
        // Plus zero turns -0.0 into 0.0, so a spacing that crosses zero keys
        // every cache downstream as the no spacing it shapes as.
        first_finite_override(&self.letter_spacing_overrides, base) + 0.0
    }

    /// The family to shape the glyphs with.
    pub(crate) fn font_family(&self) -> FontFamily {
        self.font_family
            .get()
            .unwrap_or_else(crate::default_font_family)
    }

    /// The weight to shape them at, on the CSS 100-900 scale.
    pub(crate) fn font_weight(&self) -> FontWeight {
        self.font_weight.get_or(FontWeight::NORMAL)
    }

    /// How tall each line is.
    pub(crate) fn line_height(&self) -> LineHeight {
        self.line_height.get_or(LineHeight::Normal)
    }

    /// The contour drawn around the glyphs, if one is declared.
    pub(crate) fn stroke(&self) -> Option<TextStroke> {
        self.stroke.get()
    }

    /// The shadow cast by the glyphs, if one is declared.
    pub(crate) fn shadow(&self) -> Option<TextShadow> {
        self.shadow.get()
    }
}

/// Where a declared text property keeps its motion.
///
/// Boxed and absent by default, like the style beside it: a text that declares
/// no timing pays a null check rather than two animation states. Only the two
/// interpolable properties are here — see [`declares_text_style`].
#[derive(Default)]
pub(crate) struct TextAnims {
    pub(crate) color: Option<AnimationState<Color>>,
    pub(crate) font_size: Option<AnimationState<f32>>,
}

impl TextAnims {
    /// Point both motions at what the style now resolves to, and say what has
    /// to happen next.
    ///
    /// Returns the job the frame after this one wants, and the size to measure
    /// with — the animated one while it is moving, the declared one otherwise.
    ///
    /// A motion that has never run is *seeded* rather than eased: its stored
    /// value is whatever `get_untracked` saw when the builder ran, and a write
    /// landing between construction and the first layout would otherwise make
    /// the first frame ease from a value that was already stale.
    ///
    /// **A sequence is asked for, not eased into.** A timeline waits on a
    /// trigger, so every motion is offered its play here before it is pointed
    /// anywhere — the same order `Container`'s `retarget!` uses, and for the
    /// same reason: the play has to start before the frame that shows its
    /// first value. Without it a declared timeline was installed and never
    /// played (#385).
    ///
    /// **`take_play` is asked on every pass, the first one included.** It
    /// reads the trigger, and this runs inside layout's tracking scope, so
    /// that read is the subscription that brings the next frame when the
    /// trigger moves. `Container` reads it in a pass of its own —
    /// `drift_from_target!` asks `wants_play` whatever state the animation is
    /// in — and text has no such pass, so this is where it has to happen.
    /// Asked only where a motion is already running it would never run on the
    /// first layout, nothing would be watching the trigger, and the sequence
    /// could never be asked for at all.
    pub(crate) fn retarget(
        &mut self,
        ctx: &mut crate::tree::LayoutCtx,
        color: Color,
        size: f32,
        now: FrameInstant,
    ) -> (Option<RequiredJob>, f32) {
        let mut wants = None;
        if let Some(a) = self.color.as_mut() {
            let play = a.take_play();
            if a.is_initial() {
                if !a.begin_enter(ctx, color, || now) {
                    a.set_immediate(ctx, color);
                }
                // After the seed, so a sequence that plays because the widget
                // exists is not immediately overwritten by the value it was
                // seeded with.
                if play {
                    a.play(now);
                }
            } else {
                if play {
                    a.play(now);
                }
                a.animate_to(color, now);
            }
            if a.is_animating() {
                wants = Some(RequiredJob::Paint);
            }
        }
        let mut measured = size;
        if let Some(a) = self.font_size.as_mut() {
            let play = a.take_play();
            if a.is_initial() {
                if !a.begin_enter(ctx, size, || now) {
                    a.set_immediate(ctx, size);
                }
                if play {
                    a.play(now);
                }
            } else {
                if play {
                    a.play(now);
                }
                a.animate_to(size, now);
            }
            measured = a.displayed_in(ctx);
            // A size still moving has to be measured again, not merely redrawn.
            if a.is_animating() {
                wants = Some(RequiredJob::Layout);
            }
        }
        (wants, measured)
    }

    /// Move both motions on, and say what the next frame wants.
    ///
    /// `None` where nothing moved: a transition inside its `delay_ms` is
    /// animating and has produced no new value, and asking for a paint there
    /// would wake the loop every frame for a picture that has not changed.
    pub(crate) fn advance(&mut self, now: FrameInstant) -> Option<RequiredJob> {
        let mut wants = None;
        if let Some(a) = self.color.as_mut()
            && a.advance(now).is_changed()
        {
            wants = Some(RequiredJob::Paint);
        }
        if let Some(a) = self.font_size.as_mut()
            && a.advance(now).is_changed()
        {
            wants = Some(RequiredJob::Layout);
        }
        wants
    }

    /// Whether either motion is still on its way, which is what keeps the
    /// frames coming while a delay is running.
    pub(crate) fn is_animating(&self) -> bool {
        self.color.as_ref().is_some_and(|a| a.is_animating())
            || self.font_size.as_ref().is_some_and(|a| a.is_animating())
    }

    /// Whether a colour motion was declared. The declared colour has to be read
    /// under layout tracking to retarget it, and that subscription is worth
    /// paying only where there is something to retarget — every other text
    /// reads its colour at paint alone, as it always did.
    pub(crate) fn animates_color(&self) -> bool {
        self.color.is_some()
    }
}

/// The vocabulary for declaring text style, on a widget that draws glyphs.
///
/// Written once and emitted for both [`Text`](crate::widgets::Text) and
/// [`TextInput`](crate::widgets::TextInput), which is what keeps the two in
/// step: a property added here reaches both, and one that reaches only one of
/// them cannot be written.
///
/// These are the *declaration* sites, so `color` and `font_size` carry how they
/// move as well as what they are — `color(theme.warn.transition(200.0))`. The
/// rest take values only: a family and a weight snap to an installed face, a
/// line height may change its kind between two values, and a stroke and a
/// shadow are records with no `Animatable` between them.
///
/// The *override* site is [`TextStyle`], and its setters take values alone.
/// That is not a second vocabulary but the rule falling out of the types: a
/// timing on `when_hovered(|s| s.color(..))` is a compile error rather than a
/// value quietly ignored, which is the same shape `Container` and `StateStyle`
/// already have.
macro_rules! declares_text_style {
    ($widget:ty, $style:ident, $anims:ident) => {
        $crate::widgets::text_style::declares_text_style!(impl[] $widget, $style, $anims);
    };
    // A generic widget names its parameters: `impl[C: Content] TextInput<C>`.
    (impl[$($generics:tt)*] $widget:ty, $style:ident, $anims:ident) => {
        impl<$($generics)*> $widget {
            fn text_style_mut(&mut self) -> &mut $crate::widgets::text_style::TextStyle {
                self.$style.get_or_insert_with(Default::default)
            }

            /// This widget's own declaration, and the active state overrides
            /// that outrank it.
            ///
            /// Emitted for both widgets rather than written twice: the two
            /// differ in nothing but which field holds the declaration, and a
            /// widget that resolved its style its own way would be the place the
            /// two drift apart. What stays per widget is
            /// `is_state_active` — a text with no control above it answers for
            /// its own hover, and a field for its own.
            ///
            /// Called inside the caller's tracking scope, and it has to be: the
            /// walk reads no declared *value* — collecting those is all it does,
            /// and `ResolvedTextStyle`'s accessors are what read them and so what
            /// subscribe to them — but `is_state_active` reads the conditions, and
            /// that is how a state change re-measures and repaints the widget it
            /// belongs to. Hoisting this out of the pass, or keeping its answer
            /// across passes, would cost exactly those subscriptions.
            fn resolved_text_style(
                &self,
                tree: &$crate::tree::Tree,
                id: $crate::tree::WidgetId,
            ) -> $crate::widgets::text_style::ResolvedTextStyle {
                let mut style = $crate::widgets::text_style::ResolvedTextStyle::default();
                // Last declared first, so the nearest override outranks the ones
                // before it, and this widget's own declaration outranks none of
                // them — it is what they fall through to.
                if !self.states.is_empty() {
                    let control = tree.nearest_control(id);
                    for (when, override_) in self.states.iter().rev() {
                        if self.is_state_active(id, control.as_ref(), when) {
                            style.push_override(override_);
                        }
                    }
                }
                if let Some(own) = self.$style.as_deref() {
                    style.push_own(own);
                }
                style
            }

            /// Point the declared motions at the resolved style, and hand back
            /// the size to measure with.
            ///
            /// Emitted rather than written per widget: the setters above are
            /// the same for both, and so is what has to happen behind them — a
            /// widget that took a transition and did not carry it would be the
            /// silently-dropped value the whole rule exists to prevent.
            fn retarget_text_anims(
                &mut self,
                ctx: &mut $crate::tree::LayoutCtx,
                id: $crate::tree::WidgetId,
                color: $crate::widgets::Color,
                size: f32,
            ) -> f32 {
                let Some(anims) = self.$anims.as_deref_mut() else {
                    return size;
                };
                let now = ctx.tree_ref().frame_instant();
                let (wants, measured) = anims.retarget(ctx, color, size, now);
                if let Some(required) = wants {
                    $crate::jobs::request_job(id, $crate::jobs::JobRequest::Animation(required));
                }
                measured
            }

            /// Move the declared motions on, and ask for the frame that carries
            /// them further.
            fn advance_text_anims(
                &mut self,
                tree: &$crate::tree::Tree,
                id: $crate::tree::WidgetId,
            ) -> bool {
                let now = tree.frame_instant();
                let Some(anims) = self.$anims.as_deref_mut() else {
                    return false;
                };
                let moved = anims.advance(now);
                let running = anims.is_animating();
                if let Some(required) = moved {
                    $crate::jobs::request_job(id, $crate::jobs::JobRequest::Animation(required));
                } else if running {
                    // Inside a delay: still on its way, nothing new to draw, so
                    // it asks to be woken without asking for a picture.
                    $crate::jobs::request_job(
                        id,
                        $crate::jobs::JobRequest::Animation($crate::jobs::RequiredJob::None),
                    );
                }
                running
            }

            /// Whether a colour motion was declared — see
            /// [`TextAnims::animates_color`].
            fn animates_text_color(&self) -> bool {
                self.$anims
                    .as_deref()
                    .is_some_and($crate::widgets::text_style::TextAnims::animates_color)
            }

            /// Colour of the glyphs, and how it moves.
            pub fn color<M>(
                mut self,
                color: impl $crate::animation::IntoAnimated<$crate::widgets::Color, M>,
            ) -> Self {
                let declared = $crate::widgets::container::declare(
                    &mut self.$anims,
                    color,
                    |a: &mut $crate::widgets::text_style::TextAnims| &mut a.color,
                );
                self.text_style_mut().color = declared;
                self
            }

            /// Font size in logical pixels, and how it moves.
            pub fn font_size<M>(
                mut self,
                size: impl $crate::animation::IntoAnimated<f32, M>,
            ) -> Self {
                let declared = $crate::widgets::container::declare(
                    &mut self.$anims,
                    size,
                    |a: &mut $crate::widgets::text_style::TextAnims| &mut a.font_size,
                );
                self.text_style_mut().font_size = declared;
                self
            }

            /// Font family.
            pub fn font_family<M>(
                mut self,
                family: impl $crate::reactive::IntoSignal<$crate::widgets::FontFamily, M>,
            ) -> Self {
                self.text_style_mut().font_family = family.into_prop();
                self
            }

            /// Font weight on the CSS 100-900 scale.
            pub fn font_weight<M>(
                mut self,
                weight: impl $crate::reactive::IntoSignal<$crate::widgets::FontWeight, M>,
            ) -> Self {
                self.text_style_mut().font_weight = weight.into_prop();
                self
            }

            /// Shorthand for [`font_weight`](Self::font_weight) at `FontWeight::BOLD`.
            pub fn bold(self) -> Self {
                self.font_weight($crate::widgets::FontWeight::BOLD)
            }

            /// How tall each line is. A bare number is a factor of the font
            /// size; [`LineHeight::Absolute`]($crate::widgets::LineHeight::Absolute)
            /// is logical pixels, and the default is the font's own.
            pub fn line_height<M>(
                mut self,
                height: impl $crate::reactive::IntoSignal<$crate::widgets::LineHeight, M>,
            ) -> Self {
                self.text_style_mut().line_height = height.into_prop();
                self
            }

            /// Shorthand for [`font_family`](Self::font_family) at the monospace family.
            pub fn mono(self) -> Self {
                self.font_family($crate::widgets::FontFamily::Monospace)
            }

            /// Extra advance after every glyph, in logical pixels: `0.0`,
            /// the default, is the font's own spacing, and a negative value
            /// tightens it. See [`TextStyle::letter_spacing`]($crate::widgets::TextStyle::letter_spacing)
            /// for what it is added to.
            pub fn letter_spacing<M>(
                mut self,
                spacing: impl $crate::reactive::IntoSignal<f32, M>,
            ) -> Self {
                self.text_style_mut().letter_spacing = spacing.into_prop();
                self
            }

            /// Contour drawn around the glyphs, under the fill.
            pub fn text_stroke<M>(
                mut self,
                stroke: impl $crate::reactive::IntoSignal<$crate::widgets::TextStroke, M>,
            ) -> Self {
                self.text_style_mut().stroke = stroke.into_prop();
                self
            }

            /// Soft shadow cast by the glyphs.
            pub fn text_shadow<M>(
                mut self,
                shadow: impl $crate::reactive::IntoSignal<$crate::widgets::TextShadow, M>,
            ) -> Self {
                self.text_style_mut().shadow = shadow.into_prop();
                self
            }
        }
    };
}

pub(crate) use declares_text_style;

/// The same vocabulary for an override, which supplies values and never a
/// timing.
///
/// The motion belongs to whoever *declared* a property, and a state layer does
/// not declare it — so the two differ by the types they accept rather than by a
/// rule written down anywhere, which is the shape `Container` and `StateStyle`
/// already have.
impl TextStyle {
    /// Colour of the glyphs.
    ///
    /// An override supplies a value and never a timing: the motion belongs to
    /// whoever *declared* the property, and a state layer does not declare it.
    /// So this is a compile error rather than a value quietly ignored, which is
    /// the whole reason [`Animated`](crate::animation::Animated) is not an
    /// [`IntoSignal`]:
    ///
    /// ```compile_fail
    /// use guido::prelude::*;
    ///
    /// const HOT: Color = Color::rgb(0.9, 0.3, 0.2);
    /// let _ = text("label").when_hovered(|s| s.color(HOT.transition(80.0)));
    /// ```
    pub fn color<M>(mut self, color: impl IntoSignal<Color, M>) -> Self {
        self.color = color.into_prop();
        self
    }

    /// Font size in logical pixels.
    pub fn font_size<M>(mut self, size: impl IntoSignal<f32, M>) -> Self {
        self.font_size = size.into_prop();
        self
    }

    /// Font family.
    pub fn font_family<M>(mut self, family: impl IntoSignal<FontFamily, M>) -> Self {
        self.font_family = family.into_prop();
        self
    }

    /// Font weight on the CSS 100-900 scale.
    pub fn font_weight<M>(mut self, weight: impl IntoSignal<FontWeight, M>) -> Self {
        self.font_weight = weight.into_prop();
        self
    }

    /// Shorthand for [`font_weight`](Self::font_weight) at `FontWeight::BOLD`.
    pub fn bold(self) -> Self {
        self.font_weight(FontWeight::BOLD)
    }

    /// How tall each line is. A bare number is a factor of the font size.
    pub fn line_height<M>(mut self, height: impl IntoSignal<LineHeight, M>) -> Self {
        self.line_height = height.into_prop();
        self
    }

    /// Shorthand for [`font_family`](Self::font_family) at the monospace family.
    pub fn mono(self) -> Self {
        self.font_family(FontFamily::Monospace)
    }

    /// Extra advance after every glyph, in logical pixels: `0.0`, the
    /// default, is the font's own spacing, and a negative value tightens it.
    ///
    /// A length and not a fraction of the size, so a design token of `1.5`
    /// is a pixel and a half at any font size. What it is added to is
    /// cosmic-text's: every glyph the shaper puts out, the last on a line
    /// included — so a ligature takes one spacing for the letters it joins,
    /// a combining mark the shaper cannot compose into its letter one of its
    /// own, and a joined Arabic word is pulled apart between its letters. A
    /// value that is not a number is `0.0`.
    pub fn letter_spacing<M>(mut self, spacing: impl IntoSignal<f32, M>) -> Self {
        self.letter_spacing = spacing.into_prop();
        self
    }

    /// Contour drawn around the glyphs, under the fill.
    pub fn text_stroke<M>(mut self, stroke: impl IntoSignal<TextStroke, M>) -> Self {
        self.stroke = stroke.into_prop();
        self
    }

    /// Soft shadow cast by the glyphs.
    pub fn text_shadow<M>(mut self, shadow: impl IntoSignal<TextShadow, M>) -> Self {
        self.shadow = shadow.into_prop();
        self
    }
}

#[cfg(test)]
mod a_constant_text_property_is_a_constant {
    use super::*;
    use crate::reactive::storage::slot_count;
    use crate::widgets::text;

    /// A family named with a value claims no signal slot; the same family
    /// behind a closure claims one.
    ///
    /// The shape `a_constant_claims_no_slot_where_a_closure_claims_one` uses
    /// on `Container`'s eleven properties, for the last property that could
    /// not take it. `font_family` held an `Option<Signal<FontFamily>>` while
    /// every other property held a `Prop`, because `Prop` stores its value
    /// inline and `FontFamily` was not `Copy` — so declaring a family that
    /// cannot change still allocated somewhere to keep it.
    ///
    /// Both halves. That the closure still claims a slot is what says this is
    /// looking at the declaration rather than at a widget that happens to
    /// allocate nothing.
    /// Slots claimed by building `what` once, with a warm-up build first so
    /// that whatever a path sets up one time is behind the measurement.
    fn slots(what: impl Fn()) -> usize {
        what();
        let mark = slot_count();
        what();
        slot_count() - mark
    }

    #[test]
    fn a_named_family_claims_no_slot_where_a_closure_claims_one() {
        let constant = slots(|| drop(text("x").font_family(FontFamily::name("Inter"))));
        let reactive = slots(|| drop(text("x").font_family(|| FontFamily::name("Inter"))));

        assert_eq!(
            constant,
            reactive - 1,
            "a named family must claim no slot and a closure must claim one — \
             got {constant} against {reactive}"
        );
    }

    /// And a generic family, which is the commonest of all, costs what
    /// declaring no family costs.
    ///
    /// Against a bare `text` rather than zero, so this says what the family
    /// costs whatever the text itself does — which for a literal is nothing
    /// now (`a_container_and_a_literal_text_claim_no_slot`).
    #[test]
    fn a_generic_family_costs_what_declaring_none_costs() {
        drop(text("x").font_family(FontFamily::Monospace));

        let mark = slot_count();
        drop(text("x"));
        let bare = slot_count() - mark;

        let mark = slot_count();
        drop(text("x").font_family(FontFamily::Monospace));
        let named = slot_count() - mark;

        let mark = slot_count();
        drop(text("x").mono());
        let shorthand = slot_count() - mark;

        assert_eq!(
            named, bare,
            "`Monospace` is a discriminant, not a value to keep"
        );
        assert_eq!(
            shorthand, bare,
            "and `mono()` is the same declaration spelled shorter"
        );
    }
}