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
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
//! Resolving a container's styled values.
//!
//! Every visual property of a container exists at three levels, and this
//! module is the only place that knows how they combine:
//!
//! 1. the **base** value the builder was given (a signal, possibly a closure);
//! 2. the **state layer** override that applies while the container is
//!    pressed, focused or hovered — pressed wins, then focused, then hovered;
//! 3. the **animation** currently interpolating toward that value.
//!
//! The two families below correspond to the last two steps. `effective_*`
//! answers "what should this property be right now" — the *target*, base plus
//! state layer, which is what an animation is asked to animate toward.
//! `animated_*` answers "what should be drawn right now" — the in-flight value
//! when an animation exists, the target otherwise.
//!
//! Layout and paint both read through these, which is why they live apart from
//! either: a state-layer change has to move the same value whether it lands in
//! a size or in a colour.

use super::*;
use crate::finite::{AllFinite, FiniteOr};
use crate::tree::LayoutCtx;
use crate::widgets::BackgroundOverride;

impl Container {
    // -----------------------------------------------------------------------
    // State layer: base value plus the override of the active state
    // -----------------------------------------------------------------------

    /// Apply the active state layer's override to `base`, if it declares one.
    /// Priority is pressed > focused > hovered.
    fn resolve_state_value<T: AllFinite + Clone>(
        &self,
        id: WidgetId,
        base: T,
        extractor: impl Fn(&StateStyle) -> Option<T>,
    ) -> T {
        let Some(ref ix) = self.interaction else {
            return base;
        };
        // Ask "does anything declare a state?" before touching the signals.
        // Reading them first would make every interactive container — a bare
        // `on_click`, say — subscribe to its own hover and start repainting on
        // every pointer move, for a state layer it does not have.
        if !ix.has_any_state() {
            return base;
        }

        // Built once for the whole walk rather than per layer: eight properties
        // resolve per paint, each walking these same layers.
        let control = ix.as_control(id);

        // Backwards: the last layer declared wins. A layer that says nothing
        // about *this* property is passed over rather than ending the search,
        // so `when_hovered(|s| s.lighter(0.1))` still lightens under a pressed
        // layer that only scales.
        for (when, state) in ix.states.iter().rev() {
            // A layer whose value is not finite is passed over exactly as one
            // that says nothing about this property is — the base it falls
            // through to was resolved the same way, so what comes out of here
            // is finite whatever the application computed.
            let Some(value) = extractor(state).filter(|v| v.all_finite()) else {
                continue;
            };
            if control.is_active(when) {
                return value;
            }
        }
        base
    }

    /// The background a state layer resolves to: its own colour if it declares
    /// one, its own alpha if it declares one, otherwise the base.
    pub(super) fn effective_background_target(&self, id: WidgetId) -> Color {
        let base = self
            .background
            .get_finite_or(Color::TRANSPARENT, id, "background");
        self.resolve_state_value(id, base, |state| state_colour(base, state))
    }

    /// The gradient a state layer resolves to: each end resolved the way
    /// [`effective_background_target`] resolves a solid fill, or no gradient at
    /// all while an exact background replaces it.
    ///
    /// [`effective_background_target`]: Self::effective_background_target
    pub(super) fn effective_gradient(&self, id: WidgetId) -> Option<LinearGradient> {
        let base = self.gradient_prop().get().flatten()?;
        self.resolve_state_value(id, Some(base), |state| {
            if let Some(BackgroundOverride::Exact(_)) = state.background {
                // Passed over, as the solid fill passes it over, when the colour
                // it would replace the gradient with is not a number.
                return state_colour(base.start_color, state)
                    .filter(AllFinite::all_finite)
                    .map(|_| None);
            }
            let (start_color, end_color) =
                state_colour(base.start_color, state).zip(state_colour(base.end_color, state))?;
            Some(Some(LinearGradient {
                start_color,
                end_color,
                ..base
            }))
        })
    }

    pub(super) fn effective_border_width_target(&self, id: WidgetId) -> f32 {
        let base = self.border_width.get_finite_or(0.0, id, "border_width");
        self.resolve_state_value(id, base, |state| state.border.map(|b| b.width.get()))
    }

    pub(super) fn effective_border_color_target(&self, id: WidgetId) -> Color {
        let base = self
            .border_color
            .get_finite_or(Color::TRANSPARENT, id, "border_color");
        self.resolve_state_value(id, base, |state| state.border.map(|b| b.color.get()))
    }

    pub(super) fn effective_corners_target(&self, id: WidgetId) -> crate::widgets::Corners {
        let base = self
            .corners
            .get_finite_or(crate::widgets::Corners::SQUARE, id, "corners");
        self.resolve_state_value(id, base, |state| state.corners.get())
    }

    pub(super) fn effective_translate_target(&self, id: WidgetId) -> Translate {
        let base = self
            .translate_prop()
            .get_finite_or(Translate::NONE, id, "translate");
        self.resolve_state_value(id, base, |state| state.translate.get())
    }

    pub(super) fn effective_rotate_target(&self, id: WidgetId) -> f32 {
        let base = self.rotate_prop().get_finite_or(0.0, id, "rotate");
        self.resolve_state_value(id, base, |state| state.rotate.get())
    }

    pub(super) fn effective_scale_target(&self, id: WidgetId) -> Scale {
        let base = self.scale_prop().get_finite_or(Scale::NONE, id, "scale");
        self.resolve_state_value(id, base, |state| state.scale.get())
    }

    /// The declared opacity. No state layer overrides one, so the declaration
    /// is the whole of it.
    pub(super) fn effective_opacity_target(&self, id: WidgetId) -> f32 {
        self.opacity_prop().get_finite_or(1.0, id, "opacity")
    }

    /// How far past its own bounds the deepest shadow this container can cast
    /// reaches, and the number its damage rect is sized by.
    ///
    /// Layout's, not paint's. A shadow animates paint-only, so a hover that
    /// lifts a card never re-runs this layout, and layout is where the shadow's
    /// reach is recorded. Sizing the reach to whatever is showing would leave
    /// the ring outside every damage rect.
    ///
    /// So the answer is a bound over everything the shadow can be:
    ///
    /// - the declared shadow, and every state layer's,
    /// - inflated by the overshoot a spring still has to come,
    /// - **and whatever is in flight**, because a *shrinking* shadow is drawn
    ///   from a value the declarations no longer mention. `.shadow(move ||
    ///   pick.get())` written from deep to none re-runs this layout on the
    ///   write, at which point every declaration reads as nothing while the
    ///   shadow on screen is still 8 deep.
    /// - **and every stop of a declared timeline**, which no signal mentions
    ///   either. A sequence's keyframes are values the shadow will hold, and
    ///   `animated_shadow` clamps whatever is in flight to this number — so
    ///   leaving them out meant a timeline that grows the shadow was scaled
    ///   straight back down to where it started. The animation ran, `advance`
    ///   moved it, and the painted blur was the declared value on every frame
    ///   of it (#384). `transition` was never affected: it moves the declared
    ///   signal, which this reads, so the reach follows it.
    ///
    /// Reading every state layer under layout tracking is what the comment on
    /// [`max_transform_reach`](Self::max_transform_reach) is about: every shadow
    /// *any* state layer declares is a layout dependency, so a container whose
    /// hover shadow is a constant re-lays out only when the declaration changes.
    /// `.shadow(none).when_hovered(|s| s.shadow(lifted))` — both constants — is
    /// laid out once; `when_hovered(|s| s.shadow(lift))` with `lift` a signal
    /// re-lays out the container when it moves.
    pub(super) fn max_shadow_extent(&self, id: WidgetId) -> f32 {
        let base = self
            .shadow_prop()
            .get_finite_or(Shadow::none(), id, "shadow");
        let anim = self.anims.as_ref().and_then(|a| a.shadow());
        // Signed, because a step between two shadows pulled inside the box can
        // overshoot past its edge though neither end reaches it. The low end
        // starts at zero, so a shadow rising from none, or from any positive
        // declaration, keeps the bound it had: a fraction of its own depth.
        let resting = base.signed_extent();
        let (floor, deepest) = self
            .interaction
            .iter()
            .flat_map(|ix| ix.states.iter())
            .filter_map(|(_, state)| state.shadow.get())
            .filter(AllFinite::all_finite)
            .map(|shadow| shadow.signed_extent())
            .fold((resting.min(0.0), resting), |(low, high), extent| {
                (low.min(extent), high.max(extent))
            });

        match anim {
            // The value in flight is already past its overshoot, so it is folded
            // in flat; the declarations have theirs still to come.
            //
            // Scaling the extent by the overshoot is exact only while every
            // channel keeps its sign, which a level guaranteed and a shadow does
            // not: `(0.0, -100.0)` to `(0.0, 100.0)` are both 100 deep, and at
            // the peak of a bounce the offset is 134. `animated_shadow` shrinks
            // whatever is in flight to this number, so that is a visibly damped
            // peak on a sign-crossing bounce rather than a ring outside the
            // damage rect — the same one-sided error the clamp already trades
            // for not sizing every rect to a resonant gain.
            Some(anim) => (deepest + (deepest - floor) * anim.peak_overshoot())
                .max(anim.current().extent())
                .max(anim.sequence_reach(Shadow::signed_extent)),
            None => deepest.max(0.0),
        }
    }

    /// Which of the three components anything can move — the declaration, a
    /// state layer's override of it, or an animation holding one.
    ///
    /// One computation, read by `animated_transform`'s gate and by
    /// `max_transform_reach`'s. Two copies of this drifting apart would make a
    /// container that transforms report a reach of zero and be culled while it
    /// is moved, which is the defect this all exists to stop.
    pub(super) fn moving_components(&self) -> Moves {
        let anims = self.anims.as_ref();
        let declared = self
            .interaction
            .as_ref()
            .map(|ix| ix.declares_transform)
            .unwrap_or_default();
        Moves {
            translate: declared.translate
                || self.translate_prop().is_set()
                || anims.is_some_and(|a| a.translate().is_some()),
            rotate: declared.rotate
                || self.rotate_prop().is_set()
                || anims.is_some_and(|a| a.rotate().is_some()),
            scale: declared.scale
                || self.scale_prop().is_set()
                || anims.is_some_and(|a| a.scale().is_some()),
        }
    }

    /// The furthest this container's paint can land outside `bounds`, in
    /// logical pixels — its shadow, and then whatever its transform can do to
    /// the box that shadow surrounds.
    ///
    /// The largest it can reach *from what is declared* — the value, the state
    /// layers, and whatever an animation is holding as this is asked. A
    /// `timeline` is not bounded here: its keyframes are the animation's, not a
    /// declaration, so the answer for one is the frame it is on rather than the
    /// whole play. That is sound because a playing animation queues a Paint job
    /// per changed frame and this is recomputed from it, which
    /// `a_declared_transform_change_invalidates_the_reach_without_a_layout`
    /// holds in place; it is not a bound taken once and trusted.
    ///
    /// For everything that *is* declared it is the largest, not the one
    /// showing, for the reason [`Self::max_shadow_extent`] gives: a transform
    /// animates paint-only, so a hover that lifts a card never re-runs this
    /// layout. A reach sized to the resting value would leave the lifted card
    /// outside the rect its parent culls against, and it would vanish for
    /// exactly as long as it was moved.
    ///
    /// A bound rather than a value, so the error is one-sided. Everything
    /// downstream grows a laid-out rect by this and asks whether the result is
    /// visible; a child's drawn rect is inside its grown rect by construction,
    /// so a child on screen is never culled. Sometimes one is painted that has
    /// not moved into view yet.
    ///
    /// The two outsets compose rather than alternate: a shadow is drawn in the
    /// container's own space and then carried by the transform, so an elevated
    /// card that lifts reaches further than either alone. Taking the transform
    /// over the shadow-inflated box is also what makes `scale` scale the
    /// shadow.
    ///
    /// The declared transform and the pivot are read *tracked*, and both
    /// callers of
    /// `Container::refresh_paint_bounds` open this container's own *Paint*
    /// scope around it — the Paint job in `jobs.rs`, and `Tree::layout_widget`
    /// after the Layout scope has closed. So a write to a transform marks the
    /// container that declared it for repaint, and marks nothing for layout.
    ///
    /// They were `_untracked` until #319, and the reason given was that layout
    /// ran a child inside its *parent's* scope, so a tracked read would
    /// register against an ancestor and reflow it. #387 ended that: a child is
    /// laid out through one call that opens the child's own scope. What was
    /// left was a widget culled before it ever painted, which had read nothing
    /// and so could never be told to come back —
    /// `a_row_that_never_painted_can_still_lift_itself_into_view`.
    ///
    /// The schedule still carries the ordinary case: the publish runs in the
    /// pass before this frame paints, as Blink resolves its transforms in the
    /// same gap. The subscription is what covers the case the schedule cannot
    /// reach. `a_transform_does_not_reflow_the_parent_either` is still what
    /// says the marking lands on the right widget and the right pass.
    ///
    /// The state-layer transforms below stay snapshots. A hover or a press
    /// cannot arrive at a widget nobody can see, so subscribing to them would
    /// buy nothing the schedule does not already cover — and would put every
    /// container that declares `when_hovered` on its own hover signal from
    /// the reach path, which is a wider fan-out than the case needs.
    pub(super) fn max_transform_reach(&self, bounds: Rect, shadow_extent: f32) -> f32 {
        let moves = self.moving_components();
        if !moves.any() {
            return shadow_extent;
        }

        // The box the transform actually carries: the container plus the
        // shadow already standing outside it. Measured back against `bounds`,
        // because that is what every consumer grows by the answer — an outset
        // taken against `painted` would report the excursion beyond the shadow
        // and lose its width.
        let painted = bounds.outset(shadow_extent);
        let pivot = self.resolved_pivot_quietly();
        let anims = self.anims.as_ref();

        // Each translate resolved against `bounds` as it is composed, since
        // that is where `animated_transform` resolves the one it paints.
        let base_translate = self.translate_prop().get_finite_or_quietly(Translate::NONE);
        let base_rotate = self.rotate_prop().get_finite_or_quietly(0.0);
        let base_scale = self.scale_prop().get_finite_or_quietly(Scale::NONE);
        let base = Transform::compose(base_translate.resolve(bounds), base_rotate, base_scale);
        let mut reach = outset_of(base, painted, bounds, pivot);

        // A rotation's outset is not monotone in its angle: a box turned 0 or
        // 180 degrees sits back where it started and somewhere between stands
        // furthest outside. Endpoints bound a shadow, which grows monotonically
        // with its extent; they bound nothing here. So a container that can
        // rotate at all is given the worst angle outright.
        //
        // Turning about a pivot keeps every point at its own distance from it,
        // so the whole swept shape fits the circle of radius `far` about the
        // pivot — which bounds every angle, and every pivot, without caring
        // which. A corner pivot sweeps a much larger circle than a centre one,
        // and half the diagonal only ever describes the centre.
        if moves.rotate {
            // About `bounds`, which is where `outset_of` and `flatten` resolve
            // it. Resolving against the shadow-inflated box instead puts the
            // centre of the swept circle somewhere the content never turns
            // about, and the bound comes out looser than the widest angle.
            let (px, py) = pivot.resolve(bounds);
            let far = [
                (painted.x, painted.y),
                (painted.x + painted.width, painted.y),
                (painted.x, painted.y + painted.height),
                (painted.x + painted.width, painted.y + painted.height),
            ]
            .into_iter()
            .fold(0.0f32, |most, (x, y)| most.max((x - px).hypot(y - py)));
            let swept = Rect::new(px - far, py - far, far * 2.0, far * 2.0);
            reach = reach.max(swept.outset_beyond(bounds));
        }

        // Each layer is a transform this container can be in, whether or not it
        // is in it now. Resolving only the active one would make a hover re-run
        // layout, which is the whole thing this avoids.
        if let Some(ref ix) = self.interaction {
            for (_, state) in ix.states.iter() {
                if !state.translate.is_set() && !state.rotate.is_set() && !state.scale.is_set() {
                    continue;
                }
                let candidate = Transform::compose(
                    state
                        .translate
                        .get_or_untracked(base_translate)
                        .resolve(bounds),
                    state.rotate.get_or_untracked(base_rotate),
                    state.scale.get_or_untracked(base_scale),
                );
                reach = reach.max(outset_of(candidate, painted, bounds, pivot));
            }
        }

        // A spring passes its target before it settles, so the declared reach
        // is inflated by the overshoot still to come — the allowance
        // `max_shadow_extent` makes for a shadow rising from none — and the
        // value in flight, which is already
        // past whatever overshoot it had, is folded in flat.
        if let Some(anims) = anims {
            let overshoot = [
                anims.translate().map(|a| a.peak_overshoot()),
                anims.rotate().map(|a| a.peak_overshoot()),
                anims.scale().map(|a| a.peak_overshoot()),
            ]
            .into_iter()
            .flatten()
            .fold(0.0f32, f32::max);
            reach *= 1.0 + overshoot;

            let flying = Transform::compose(
                anims
                    .translate()
                    .map_or(base_translate, |a| *a.current())
                    .resolve(bounds),
                anims.rotate().map_or(base_rotate, |a| *a.current()),
                anims.scale().map_or(base_scale, |a| *a.current()),
            );
            reach = reach.max(outset_of(flying, painted, bounds, pivot));
        }

        reach
    }

    pub(super) fn effective_shadow_target(&self, id: WidgetId) -> Shadow {
        let base = self
            .shadow_prop()
            .get_finite_or(Shadow::none(), id, "shadow");
        self.resolve_state_value(id, base, |state| state.shadow.get())
    }

    // -----------------------------------------------------------------------
    // Animation: the in-flight value when one is running
    // -----------------------------------------------------------------------

    /// The declared pivot, with one nobody can compute reading as undeclared —
    /// the same rule every other property is resolved by, spelled here because
    /// a pivot has no state layer to be resolved through.
    pub(super) fn resolved_pivot(&self, id: WidgetId) -> Pivot {
        self.pivot_prop().get_finite_or(Pivot::CENTER, id, "pivot")
    }

    /// The same, for the reach calculation: it subscribes like the paint
    /// beside it, and says nothing about a value that is not a number, because
    /// that paint is reading the same signal in the same frame and one warning
    /// is the point.
    pub(super) fn resolved_pivot_quietly(&self) -> Pivot {
        self.pivot_prop().get_finite_or_quietly(Pivot::CENTER)
    }

    /// The declared padding, coerced like every other property.
    ///
    /// Its own accessor because four places read it — the seeding pass, the
    /// drift check, layout and the animated reader — and a property resolved
    /// four ways is a property three of them will eventually resolve wrongly.
    pub(super) fn effective_padding_target(&self, id: WidgetId) -> Padding {
        self.padding
            .get_finite_or(Padding::default(), id, "padding")
    }

    pub(super) fn animated_padding(&self, ctx: &mut LayoutCtx, id: WidgetId) -> Padding {
        match self.anims.as_ref().and_then(|a| a.padding()) {
            Some(anim) => anim.displayed_in(ctx),
            None => self.effective_padding_target(id),
        }
    }

    pub(super) fn animated_background(&self, id: WidgetId) -> Color {
        get_animated_value(self.anims.as_ref().and_then(|a| a.background()), || {
            self.effective_background_target(id)
        })
    }

    pub(super) fn animated_corners(&self, id: WidgetId) -> crate::widgets::Corners {
        get_animated_value(self.anims.as_ref().and_then(|a| a.corners()), || {
            self.effective_corners_target(id)
        })
    }

    pub(super) fn animated_border_width(&self, id: WidgetId) -> f32 {
        get_animated_value(self.anims.as_ref().and_then(|a| a.border_width()), || {
            self.effective_border_width_target(id)
        })
    }

    pub(super) fn animated_opacity(&self, id: WidgetId) -> f32 {
        get_animated_value(self.anims.as_ref().and_then(|a| a.opacity()), || {
            self.effective_opacity_target(id)
        })
    }

    pub(super) fn animated_border_color(&self, id: WidgetId) -> Color {
        get_animated_value(self.anims.as_ref().and_then(|a| a.border_color()), || {
            self.effective_border_color_target(id)
        })
    }

    /// The shadow to draw, never reaching further than the rect the layout
    /// reserved.
    ///
    /// Clamped, and clamped to [`shadow_reach`](super::Container::shadow_reach)
    /// — the number `layout` recorded — rather than to a fresh
    /// [`max_shadow_extent`](Self::max_shadow_extent). Recomputing it here reads
    /// the signals at *paint* time, which is a different frame's answer: on a
    /// shrinking shadow the declared maximum has already reached nothing and the
    /// shadow was cut away while the animation was still playing.
    ///
    /// Uniformly — see [`Shadow::shrunk_to`] — because a shadow is four numbers
    /// and the reserved rect is one.
    ///
    /// A clamp at all, rather than a wider reach, because the reach that would
    /// make one unnecessary is not a small one. A spring keeps its momentum
    /// across a retarget, so hover flicker *pumps* it: driven at its damped
    /// natural frequency the steady-state excursion is the resonant gain,
    /// `1 / (2ζ√(1-ζ²))` — 1.03x the step response for `BOUNCY`, but 3.7x at
    /// ζ = 0.05, and sizing every damage rect for that is not a trade worth
    /// making for the tip of a bounce nobody asked for. See
    /// `hover_flicker_cannot_push_a_shadow_outside_its_damage_rect`.
    pub(super) fn animated_shadow(&self, id: WidgetId) -> Shadow {
        let anim = self.anims.as_ref().and_then(|a| a.shadow());
        let shadow = get_animated_value(anim, || self.effective_shadow_target(id));
        match anim {
            Some(_) => shadow.shrunk_to(self.shadow_reach.get()),
            // Only where something is in flight, because `shadow_reach` is the
            // number *layout* recorded and layout has not run yet on the frame
            // a container is first painted — it is `0.0` until then, and
            // clamping to it would draw no shadow at all. Everything a
            // declaration or a state layer can resolve to is already folded
            // into `max_shadow_extent`, so an unanimated shadow is inside the
            // rect by construction and has nothing to be clamped to.
            None => shadow,
        }
    }

    /// The three declared components, each at whatever its own animation has
    /// reached, composed into the matrix the renderer and the hit test share.
    ///
    /// This is the only place the two forms meet. Everything above it says
    /// `translate`, `rotate`, `scale`; everything below it takes a matrix and
    /// never has to ask how the matrix was arrived at.
    ///
    /// `bounds` is what a relative translate is a fraction of, resolved here
    /// and not before, for the reason `Pivot` is resolved at flatten: what
    /// animates is the fraction, so a width change moves the box at once
    /// rather than starting a slide the declaration never asked for.
    pub(super) fn animated_transform(&self, id: WidgetId, bounds: Rect) -> Transform {
        let anims = self.anims.as_ref();

        // What could move each component: its own declaration, its own
        // animation, or a state layer that overrides it. Per component and not
        // "does any layer move anything", because `when_pressed(|s|
        // s.scale(0.98))` is on every button and one bit for all three would
        // have made it resolve a translate and a rotate nothing declares,
        // twice per pointer move.
        //
        // Computed once and shared with the early-out below, so the two cannot
        // disagree: a component wired into the gates and forgotten in the
        // early-out would return IDENTITY and silently stop transforming.
        let Moves {
            translate: has_translate,
            rotate: has_rotate,
            scale: has_scale,
        } = self.moving_components();

        // A plain layout box is none of the three, and most containers are one.
        if !(has_translate || has_rotate || has_scale) {
            return Transform::IDENTITY;
        }

        let translate = if has_translate {
            get_animated_value(anims.and_then(|a| a.translate()), || {
                self.effective_translate_target(id)
            })
            .resolve(bounds)
        } else {
            (0.0, 0.0)
        };
        let rotate = if has_rotate {
            get_animated_value(anims.and_then(|a| a.rotate()), || {
                self.effective_rotate_target(id)
            })
        } else {
            0.0
        };
        let scale = if has_scale {
            get_animated_value(anims.and_then(|a| a.scale()), || {
                self.effective_scale_target(id)
            })
        } else {
            Scale::NONE
        };
        Transform::compose(translate, rotate, scale)
    }

    /// Whether a state-layer change can move an animated property — i.e.
    /// whether hovering or pressing needs an Animation job rather than a plain
    /// repaint.
    ///
    /// A border belongs here now that a state layer declares it as a pair:
    /// `when_hovered(|s| s.border(14.0, GREEN))` moves the width as well as the
    /// colour, and without the width the enter queued no Animation job at all.
    /// The spring then started a frame late, and only if something else had
    /// forced that paint. Both halves are in by construction now: a property
    /// counts here when it follows a signal and does not move the box.
    pub(super) fn has_animated_state_properties(&self) -> bool {
        self.anims.as_deref().is_some_and(|declared| {
            declared
                .slots()
                .any(|slot| slot.follows_a_signal() && !slot.moves_the_box())
        })
    }
}

/// The container's own surface, resolved for one frame.
pub(super) struct Decoration {
    pub background: Color,
    pub gradient: Option<LinearGradient>,
    pub corner_radii: crate::renderer::CornerRadii,
    pub corner_curvature: f32,
    pub shadow: Shadow,
    pub border_width: f32,
    pub border_color: Color,
}

impl Container {
    /// Draw the container's own surface: background or gradient, its shadow,
    /// and the border frame. Children are painted separately, after this.
    ///
    /// `bounds` is local — the origin is the container itself, and the parent
    /// has already positioned the node.
    pub(super) fn paint_decoration(&self, ctx: &mut PaintContext, bounds: Rect, d: &Decoration) {
        // A gradient replaces the solid fill rather than layering over it — but
        // not the shadow, which belongs to the box rather than to either fill.
        // Both are reactive, so which of the two branches a container takes can
        // change between frames; a gradient that dropped the shadow meant a
        // shadow animation stopped drawing halfway through while still asking
        // for a frame at every step.
        //
        // To the box *it draws*, though: the shadow rides whichever fill runs,
        // so a container with neither — no gradient, and a background that is
        // transparent or has animated out — casts none, and `paint_overflow`
        // goes on reserving the room. A shadow with nothing above it is a smear
        // rather than a lift, so that is the behaviour; it is not a
        // free-standing command waiting for a box to belong to.
        // On the alpha rather than on whether a shadow was declared: the first
        // frames of a lift out of `Shadow::none()` carry an alpha of ~0.001,
        // where the shadow rounds to nothing and every frame would still push a
        // rect carrying it. The same gate the border gets.
        let shadow = (d.shadow.color.a > SHADOW_ALPHA_FLOOR).then_some(d.shadow);

        // Gated like the fill, the border and the shadow: a gradient between two
        // fully transparent colours draws nothing, and with both endpoints
        // reactive one animating out through transparent would push a rect per
        // frame to do it. One end still visible is still a gradient.
        let gradient = d.gradient.filter(gradient_shows);
        if let Some(ref gradient) = gradient {
            ctx.draw_rounded_rect_full(
                bounds,
                gradient.start_color,
                d.corner_radii,
                d.corner_curvature,
                None,
                shadow,
                Some(crate::renderer::Gradient {
                    start_color: gradient.start_color,
                    end_color: gradient.end_color,
                    direction: gradient.direction.into(),
                }),
            );
        } else if d.background.a > 0.0 {
            match shadow {
                Some(shadow) => ctx.draw_rounded_rect_with_shadow(
                    bounds,
                    d.background,
                    d.corner_radii,
                    d.corner_curvature,
                    shadow,
                ),
                None => ctx.draw_rounded_rect_with_curvature(
                    bounds,
                    d.background,
                    d.corner_radii,
                    d.corner_curvature,
                ),
            }
        }

        // A border that resolves to nothing visible must not reach the instance
        // buffer every frame. Both halves are always declared together, so this
        // is only reached deliberately — `border(2.0, TRANSPARENT)`,
        // `border(0.0, RED)` — or in passing, while an animated colour crosses
        // transparent.
        if border_shows(d.border_width, d.border_color) {
            ctx.draw_border_frame_with_curvature(
                bounds,
                d.border_color,
                d.corner_radii,
                d.border_width,
                d.corner_curvature,
            );
        }
    }
}

impl Container {
    /// Whether this container draws a surface of its own: a fill, a border
    /// frame or a backdrop blur, by the gates `paint` puts each through. A
    /// shadow does not count — it rides the fill, and draws nothing without
    /// one.
    ///
    /// What makes a point inside its shape a point *on* it, which covers the
    /// siblings painted beneath it (#636).
    pub(super) fn draws_its_own_surface(&self, id: WidgetId) -> bool {
        // The plain fill first: it is the common surface, and the gradient the
        // dearest to resolve.
        self.animated_background(id).a > 0.0
            || self
                .effective_gradient(id)
                .is_some_and(|g| gradient_shows(&g))
            || border_shows(
                self.animated_border_width(id),
                self.animated_border_color(id),
            )
            || self
                .backdrop_blur
                .get()
                .is_some_and(|blur| blur.asks_for_anything())
    }
}

/// A gradient between two fully transparent colours draws nothing; one end
/// still visible is still a gradient.
fn gradient_shows(gradient: &LinearGradient) -> bool {
    gradient.start_color.a > 0.0 || gradient.end_color.a > 0.0
}

/// A border draws only with both a width and a colour that shows.
fn border_shows(width: f32, color: Color) -> bool {
    width > 0.0 && color.a > 0.0
}

/// Below this the shadow is not a faint shadow, it is nothing — and a rect
/// carrying it is a rect drawn for no reason, once per frame for as long as a
/// shadow animation is leaving transparent.
pub(super) const SHADOW_ALPHA_FLOOR: f32 = 0.004;

/// How far `painted`, once `transform` has carried it, stands outside `bounds`.
///
/// Two rects because they are two questions: `painted` is everything the widget
/// draws, `bounds` is the box a parent knows it by, and the answer has to be in
/// terms of the second — every consumer grows a laid-out rect by it. With no
/// transform at all the answer is the difference between them, which is the
/// shadow, so the shadow needs no separate accounting.
///
/// One number for four sides, because everything reading it grows a rect
/// equally in every direction: a scalar cannot say a card lifts upward only,
/// and a reach that is too generous costs paint while one that is too tight
/// loses a widget.
fn outset_of(transform: Transform, painted: Rect, bounds: Rect, pivot: Pivot) -> f32 {
    let moved = if transform.is_identity() {
        painted
    } else {
        // About `bounds`, which is what `flatten` resolves the pivot against
        // when it draws. Resolving against the shadow-inflated box instead
        // would put a corner pivot in a different place here than on screen.
        transform.about(pivot, bounds).map_rect(painted)
    };
    moved.outset_beyond(bounds)
}

/// A colour as a state layer resolves it: its background override, then its
/// alpha, or `None` where the layer declares neither.
fn state_colour(base: Color, state: &StateStyle) -> Option<Color> {
    let alpha = state.alpha.get();
    if state.background.is_none() && alpha.is_none() {
        return None;
    }
    let colour = state
        .background
        .as_ref()
        .map_or(base, |bg| resolve_background(base, bg));
    Some(alpha.map_or(colour, |a| colour.with_alpha(a)))
}

/// `outset_of` is the arithmetic every cull and every damage rect is grown by,
/// and it had no test until a review worked an example by hand and found it ten
/// pixels short.
#[cfg(test)]
mod reach_tests {
    use super::*;
    use crate::animation::{Animate, SpringConfig, Transition};

    /// Turning about a corner carries the far corner around a circle of the
    /// whole diagonal, so the box sweeps well past what a centre rotation
    /// reaches. A bound written for the centre reports 20.7 here, and the
    /// answer is 70.7.
    #[test]
    fn a_rotation_about_a_corner_sweeps_further_than_one_about_the_centre() {
        let mut worst: f32 = 0.0;
        for degrees in 0..360 {
            let spin = Transform::compose((0.0, 0.0), degrees as f32, Scale::NONE);
            worst = worst.max(outset_of(spin, BOX, BOX, Pivot::TOP_LEFT));
        }
        // What a bound written for the centre would have said.
        let centre_shaped = (100.0f32.hypot(100.0) - 100.0) / 2.0;
        assert!(
            worst > centre_shaped,
            "a corner rotation reaches {worst}; a centre-shaped bound says \
             {centre_shaped} and would cull a widget that is on screen"
        );

        // What the code says: every point stays its own distance from the
        // pivot, so the swept shape fits the circle of the furthest corner.
        let swept = 100.0f32.hypot(100.0);
        assert!(
            swept >= worst,
            "the swept-circle bound {swept} does not cover the worst angle {worst}"
        );
    }

    const BOX: Rect = Rect {
        x: 0.0,
        y: 0.0,
        width: 100.0,
        height: 100.0,
    };

    /// With nothing moving, what a widget paints outside its box is its shadow.
    #[test]
    fn an_untransformed_box_reaches_exactly_its_shadow() {
        let painted = BOX.outset(10.0);
        assert_eq!(
            outset_of(Transform::IDENTITY, painted, BOX, Pivot::CENTER),
            10.0
        );
    }

    /// The shadow and the transform compose. This is the case the review
    /// found: measured against the shadow-inflated box the answer came out 50,
    /// and the shadow really reaches 60.
    #[test]
    fn a_shadow_carried_by_a_translate_reaches_past_both() {
        let painted = BOX.outset(10.0);
        let lift = Transform::compose((0.0, -50.0), 0.0, Scale::NONE);
        assert_eq!(outset_of(lift, painted, BOX, Pivot::CENTER), 60.0);
    }

    /// And a consumer growing the box by the answer contains what is drawn,
    /// which is the whole promise: a widget on screen is never culled.
    #[test]
    fn the_grown_box_contains_what_is_drawn() {
        let painted = BOX.outset(10.0);
        let lift = Transform::compose((0.0, -50.0), 0.0, Scale::NONE);
        let reach = outset_of(lift, painted, BOX, Pivot::CENTER);

        let drawn = lift.about(Pivot::CENTER, painted).map_rect(painted);
        let grown = BOX.outset(reach);
        assert!(
            grown.x <= drawn.x
                && grown.y <= drawn.y
                && grown.x + grown.width >= drawn.x + drawn.width
                && grown.y + grown.height >= drawn.y + drawn.height,
            "grown {grown:?} does not contain drawn {drawn:?}"
        );
    }

    /// A scale carries the shadow with it rather than leaving it behind.
    #[test]
    fn a_scale_scales_the_shadow_it_surrounds() {
        let painted = BOX.outset(10.0);
        let grow = Transform::compose((0.0, 0.0), 0.0, Scale::uniform(2.0));
        // The 120-wide painted box doubles about the centre it shares with
        // bounds, reaching 120 either side of it; 70 of that stands outside the
        // 100-wide bounds. A shadow that did not scale would reach 60.
        assert_eq!(outset_of(grow, painted, BOX, Pivot::CENTER), 70.0);
    }

    /// The rotation bound, asked of the container rather than of the
    /// arithmetic under it.
    ///
    /// `max_transform_reach` has its own branch for rotation — the swept circle
    /// about the pivot — and the tests below reach `outset_of` directly, so
    /// that branch was watched by nothing. It has to cover the worst angle for
    /// the pivot it is given, and a corner pivot sweeps far wider than a centre
    /// one.
    #[test]
    fn the_rotation_bound_covers_every_angle_for_the_pivot_it_is_given() {
        // BOTTOM_RIGHT included deliberately: it is the pivot whose swept
        // circle reaches furthest past the *far* edges rather than the near
        // ones, so it is the only one where the circle's width and height are
        // load-bearing rather than masked by its origin.
        for pivot in [
            Pivot::CENTER,
            Pivot::TOP_LEFT,
            Pivot::TOP,
            Pivot::BOTTOM_RIGHT,
        ] {
            let turning = container()
                .width(100.0)
                .height(100.0)
                .rotate(0.0)
                .pivot(pivot);
            // With a shadow, so the box the rotation carries does not start at
            // the origin. At the origin `x` is zero and half the arithmetic
            // that reads it is indistinguishable from arithmetic that does not.
            let shadow = 6.0;
            let painted = BOX.outset(shadow);
            let bound = turning.max_transform_reach(BOX, shadow);

            let worst = (0..360)
                .map(|deg| {
                    let spin = Transform::compose((0.0, 0.0), deg as f32, Scale::NONE);
                    outset_of(spin, painted, BOX, pivot)
                })
                .fold(0.0f32, f32::max);

            assert!(
                bound >= worst - 0.01,
                "{pivot:?}: the bound is {bound} and the widest angle reaches \
                 {worst}, so a container mid-rotation would be culled"
            );
            // And tight. A bound that is merely large is satisfied by any
            // arithmetic that errs upward, which leaves the whole computation
            // unwatched — and every pixel of slack is painted and damaged.
            assert!(
                bound <= worst + 0.01,
                "{pivot:?}: the bound is {bound} where the widest angle reaches \
                 only {worst}, so every rotation pays for reach it cannot use"
            );
        }
    }

    /// And it is a bound, not a shrug: a box that cannot rotate is not given
    /// the reach of one that can.
    #[test]
    fn a_box_that_cannot_rotate_is_not_given_a_rotation_s_reach() {
        let still = container().width(100.0).height(100.0);
        assert_eq!(still.max_transform_reach(BOX, 0.0), 0.0);
        assert_eq!(still.max_transform_reach(BOX, 6.0), 6.0, "only its shadow");
    }

    /// A spring passes its target before it settles, and the reach has to
    /// cover where it goes, not where it is going.
    ///
    /// This is the one part of the bound that is not about geometry: a
    /// `SNAPPY` translate to -100 reaches past -100 on the way, and a reach of
    /// exactly 100 culls the widget at the top of its bounce.
    #[test]
    fn a_spring_is_given_room_for_the_overshoot_it_has_not_taken_yet() {
        let sprung = container().width(100.0).height(100.0).translate(
            Translate::new(0.0, -100.0).transition(Transition::spring(SpringConfig::BOUNCY)),
        );

        let reach = sprung.max_transform_reach(BOX, 0.0);
        assert!(
            reach > 100.0,
            "a spring to -100 was given a reach of {reach}, so it is culled at \
             the far end of its own overshoot"
        );

        let eased = container()
            .width(100.0)
            .height(100.0)
            .translate(Translate::new(0.0, -100.0).transition(200.0));
        assert_eq!(
            eased.max_transform_reach(BOX, 0.0),
            100.0,
            "an eased translate overshoots nothing and should pay for nothing"
        );
    }

    /// A relative translate reaches as far as the box it is a fraction of: one
    /// width left is `BOX.width` outside it, in the declaration and in a state
    /// layer. Read as pixels it reaches nothing, and a slide that starts
    /// off-screen is culled for as long as it is sliding.
    #[test]
    fn a_relative_translate_reaches_by_the_box_it_is_a_fraction_of() {
        let declared = container()
            .width(100.0)
            .height(100.0)
            .translate(Translate::relative(-1.0, 0.0));
        assert_eq!(declared.max_transform_reach(BOX, 0.0), BOX.width);

        let layered = container()
            .width(100.0)
            .height(100.0)
            .when_hovered(|s| s.translate(Translate::relative(0.0, 0.5)));
        assert_eq!(layered.max_transform_reach(BOX, 0.0), BOX.height / 2.0);
    }

    /// A state layer that moves only one component is still a transform this
    /// container can be in.
    ///
    /// The loop skips a layer declaring none of the three, and the skip has to
    /// mean *none* — a layer that scales but does not translate still carries
    /// the container somewhere its resting declaration does not, and treating
    /// "not all three" as "none" reports a reach it does not have.
    #[test]
    fn a_state_layer_moving_one_component_still_counts() {
        let scaled = container()
            .width(100.0)
            .height(100.0)
            .when_pressed(|s| s.scale(Scale::uniform(2.0)));
        assert_eq!(
            scaled.max_transform_reach(BOX, 0.0),
            50.0,
            "a layer that doubles this box on press was not counted, so it is \
             culled at its resting size while it is pressed"
        );

        // Each of the three on its own, because the skip is a conjunction and a
        // layer declaring only the first of them is where a loosened one shows.
        let lifted = container()
            .width(100.0)
            .height(100.0)
            .when_hovered(|s| s.translate(Translate::new(0.0, -25.0)));
        assert_eq!(
            lifted.max_transform_reach(BOX, 0.0),
            25.0,
            "a layer that lifts this box on hover was not counted"
        );

        let turned = container()
            .width(100.0)
            .height(100.0)
            .when_hovered(|s| s.rotate(45.0));
        assert!(
            turned.max_transform_reach(BOX, 0.0) > 0.0,
            "a layer that turns this box on hover was not counted"
        );
    }

    /// The angle that puts a square furthest outside its box is 45 degrees,
    /// where neither endpoint of a 0-to-90 rotation is. Nothing may report a
    /// reach smaller than this while a rotation is declared.
    ///
    /// About the centre. A corner pivot sweeps a far bigger circle, which is
    /// why the bound is taken from the distance to the furthest corner rather
    /// than from half the diagonal — see the neighbour below.
    #[test]
    fn a_square_reaches_furthest_halfway_through_its_rotation() {
        let at = |deg: f32| {
            outset_of(
                Transform::compose((0.0, 0.0), deg, Scale::NONE),
                BOX,
                BOX,
                Pivot::CENTER,
            )
        };
        assert_eq!(at(0.0), 0.0);
        assert!(
            at(90.0) < 0.01,
            "a square turned 90 degrees is back in its box"
        );

        let corner = (100.0f32.hypot(100.0) - 100.0) / 2.0;
        assert!(
            (at(45.0) - corner).abs() < 0.01,
            "at 45 degrees a square stands {corner} outside its box, got {}",
            at(45.0)
        );
    }
}