Skip to main content

frus_widgets/
runtime.rs

1//! Runtime state retained between frames, **keyed by widget identity**.
2//!
3//! A field's *value* stays controlled (application state); what lives here is the
4//! widgets' own **interaction/edit** state: hover/focus, scroll offsets, and the
5//! cursor/selection position of fields. This is the foundation of reconciliation
6//! by identity (laid down at Milestone 6).
7
8use std::cell::RefCell;
9use std::collections::HashMap;
10
11use frus_core::{
12    BorderRadius, Color, Curve, Insets, Point, Primitive, Rect, Simulation, Size, TextStyle,
13};
14
15use crate::interaction::{InputState, WidgetId};
16use crate::overscroll::{edge_for, GlowEdge, ScrollGlows};
17use crate::physics::{Ballistic, ScrollPhysics};
18use crate::relayout::LayoutCache;
19use crate::ui::Scrollable;
20
21/// SingleChildScrollView offsets `(x, y)`, per scrollable region.
22pub type ScrollState = HashMap<WidgetId, (f32, f32)>;
23
24/// Edit state of an input field: cursor + selection anchor.
25///
26/// Indices are in **characters**. They may exceed the value's length (e.g.
27/// `usize::MAX` for "the end"): widgets clamp them at use.
28#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
29pub struct Edit {
30    /// Cursor position.
31    pub cursor: usize,
32    /// Selection anchor (`None` = no selection).
33    pub anchor: Option<usize>,
34    /// Range `(start, end)` **being composed** by the IME (provisional text,
35    /// underlined on screen); `None` outside composition. In character indices.
36    pub composing: Option<(usize, usize)>,
37}
38
39impl Edit {
40    /// Selected range `(start, end)`, non-empty, otherwise `None`.
41    pub fn selection_range(&self) -> Option<(usize, usize)> {
42        self.anchor
43            .map(|anchor| (anchor.min(self.cursor), anchor.max(self.cursor)))
44            .filter(|(start, end)| start < end)
45    }
46}
47
48/// Which field shows its selection bar, and what the clipboard held when it opened: the
49/// shell asks the platform once, on opening, not on every frame.
50#[derive(Copy, Clone, Debug, PartialEq)]
51pub struct ToolbarMark {
52    /// The field the bar acts on.
53    pub id: WidgetId,
54    /// Whether the clipboard held text when the bar opened, so whether Paste is offered.
55    pub can_paste: bool,
56    /// Where a right-click opened it, in the frame's coordinates: the bar goes there
57    /// rather than to the selection, as the reference's context menu goes to the last
58    /// secondary tap (milestone 618). `None` when it opened any other way.
59    pub at: Option<frus_core::Point>,
60}
61
62/// One of the two handles under a touch selection, in the field's **local** coordinates
63/// (px from its top-left corner, as on screen: any scroll of its content already applied).
64///
65/// The paint and the shell's hit test read the same answer, so a handle is taken
66/// exactly where it is drawn.
67#[derive(Copy, Clone, Debug, PartialEq)]
68pub struct SelectionHandle {
69    /// The box the handle is drawn in, hanging below its end of the selection.
70    pub rect: Rect,
71    /// The text position the handle stands for: its end of the selection, halfway down
72    /// the line. A handle hangs below its line, so a finger dragging it is aimed at the
73    /// line through this point rather than at where the finger is.
74    pub line_center: Point,
75}
76
77/// **Default** transition duration, in seconds. A widget can set its own through
78/// [`crate::widget::Widget::anim_duration`].
79pub(crate) const ANIM_DURATION: f32 = 0.12;
80
81/// The **press** transition's duration, in seconds (`ink_well.dart:998`).
82///
83/// Its own, and longer than [`ANIM_DURATION`], because the reference gives the press
84/// highlight 200 ms against the 50 ms it gives hover and focus (`ink_well.dart:995`).
85/// A press is the one interaction the pointer commits to, and it is drawn slowly enough
86/// to be seen arriving and leaving.
87pub(crate) const PRESS_DURATION: f32 = 0.2;
88
89/// The fling in flight on one scroll region: one simulation per axis, and the time
90/// elapsed since the finger let go.
91///
92/// The two axes share a clock — they were launched by the same gesture — and either
93/// may be `None`, since a fling usually runs on one axis only. The whole thing is
94/// `Copy`, so sampling it costs no allocation.
95#[derive(Clone, Copy, Debug, Default)]
96pub struct ScrollBallistic {
97    /// The fling along the x axis, if there is one.
98    pub x: Option<Ballistic>,
99    /// The fling along the y axis, if there is one.
100    pub y: Option<Ballistic>,
101    /// Seconds since the release.
102    pub elapsed: f32,
103}
104
105impl ScrollBallistic {
106    /// A fling on both axes at once (either may be `None`), starting now.
107    pub fn new(x: Option<Ballistic>, y: Option<Ballistic>) -> Self {
108        Self { x, y, elapsed: 0.0 }
109    }
110
111    /// Is there anything left to run?
112    pub fn is_empty(&self) -> bool {
113        self.x.is_none() && self.y.is_none()
114    }
115}
116
117/// How long a scrollbar waits, after its area stops moving, before it starts to go
118/// (`scrollbar.dart:18`).
119const BAR_TIME_TO_FADE: f32 = 0.6;
120/// How long a scrollbar takes to arrive, and to leave (`scrollbar.dart:17`).
121const BAR_FADE: f32 = 0.3;
122/// How long the thumb takes to reach its hovered colour (`scrollbar.dart:319`).
123const BAR_HOVER: f32 = 0.2;
124
125/// **The life of one area's scrollbar over time**: whether it is on screen at all, and
126/// how far the thumb has gone towards the colour it takes under a pointer.
127///
128/// A bar is not a permanent fixture. It arrives when the area moves, waits, and goes
129/// again — which is the whole of what makes it bearable to have one at all.
130#[derive(Copy, Clone, Debug, Default)]
131pub struct ScrollbarFade {
132    /// The offset this area was last seen at. The bar answers **movement**, and the
133    /// offset is written from the drag, the fling and the spring alike; watching the
134    /// value catches all three, and anything added later, without asking any of them
135    /// to remember to say so.
136    pub(crate) seen: (f32, f32),
137    /// Seconds since it last moved.
138    pub(crate) since: f32,
139    /// `0.0..=1.0` — how present the bar is.
140    pub opacity: f32,
141    /// `0.0..=1.0` — how far the thumb has gone towards its hovered colour.
142    pub hover: f32,
143}
144
145/// Stiffness of the scroll spring (px·s⁻²).
146const SCROLL_K: f32 = 200.0;
147/// Damping of the scroll spring.
148const SCROLL_C: f32 = 28.0;
149/// Elastic pull of the target back towards the valid bounds (per second) — the bounce.
150const SCROLL_RETRACT: f32 = 14.0;
151
152/// One scroll axis: elastic pull of the target back into `[0, max]`, then a spring
153/// from the current offset towards that target. Returns
154/// `(offset, velocity, target, moving)`.
155fn scroll_axis(current: f32, vel: f32, target: f32, max: f32, dt: f32) -> (f32, f32, f32, bool) {
156    let clamp_t = target.clamp(0.0, max);
157    // The target is pulled back towards the valid bound (overshoot → bounce).
158    let target = target + (clamp_t - target) * (1.0 - (-SCROLL_RETRACT * dt).exp());
159    let (offset, vel, _) = spring_step(current, vel, target, dt, SCROLL_K, SCROLL_C);
160    // Thresholds in pixels (spring_step is calibrated in fractions).
161    let moving = (offset - target).abs() > 0.5 || vel.abs() > 2.0 || (target - clamp_t).abs() > 0.5;
162    if moving {
163        (offset, vel, target, true)
164    } else {
165        (clamp_t, 0.0, clamp_t, false)
166    }
167}
168
169/// `offset` brought inside `[0, max]` on each axis.
170fn inside_extent(offset: (f32, f32), max: (f32, f32)) -> (f32, f32) {
171    (
172        offset.0.clamp(0.0, max.0.max(0.0)),
173        offset.1.clamp(0.0, max.1.max(0.0)),
174    )
175}
176
177/// A widget's animation progresses (`0.0..=1.0`).
178#[derive(Copy, Clone, Debug, PartialEq)]
179pub struct Anim {
180    /// The hover transition's progress.
181    pub hover: f32,
182    /// The focus transition's progress.
183    pub focus: f32,
184    /// The press (0 at rest), on its own clock: 200 ms against the 120 the other two
185    /// run on, because the reference gives the press highlight four times the fade it
186    /// gives hover and focus (`ink_well.dart:995`).
187    pub press: f32,
188    /// Opacity (1 at rest; started at 0 on mount for the fade-in).
189    pub opacity: f32,
190}
191
192impl Default for Anim {
193    fn default() -> Self {
194        Self {
195            hover: 0.0,
196            focus: 0.0,
197            press: 0.0,
198            opacity: 1.0,
199        }
200    }
201}
202
203/// **Timeline** of an implicitly animated value (`Widget::anim_target`):
204/// interpolates `from → to` according to the widget's curve and duration.
205/// `current` is the value handed to the paint. A change of target **rebases** the
206/// timeline from the current value (a clean, continuous restart).
207#[derive(Copy, Clone, Debug, PartialEq)]
208pub struct ValueAnim {
209    /// Current interpolated value (what the paint reads).
210    pub current: f32,
211    /// Starting value of the transition in progress.
212    from: f32,
213    /// Target of the transition in progress.
214    to: f32,
215    /// Time elapsed (s) since the transition started.
216    elapsed: f32,
217}
218
219impl ValueAnim {
220    /// A value **at rest** at `v` (no transition in progress).
221    fn settled(v: f32) -> Self {
222        Self {
223            current: v,
224            from: v,
225            to: v,
226            elapsed: 0.0,
227        }
228    }
229}
230
231/// Timeline of an animated **colour** (`Container::animated_color`): interpolates
232/// `from → to` per channel, according to the widget's curve and duration. Same
233/// rebase model as [`ValueAnim`], applied to a colour.
234#[derive(Copy, Clone, Debug, PartialEq)]
235struct ColorAnim {
236    current: Color,
237    from: Color,
238    to: Color,
239    elapsed: f32,
240}
241
242impl ColorAnim {
243    fn settled(c: Color) -> Self {
244        Self {
245            current: c,
246            from: c,
247            to: c,
248            elapsed: 0.0,
249        }
250    }
251}
252
253/// Timeline of an animated **text style** (`AnimatedDefaultTextStyle`): interpolates
254/// `from → to` field by field, according to the widget's curve and duration.
255///
256/// Unlike every other family here, what it drives is not the node's own box or paint but
257/// **the theme its subtree inherits** — so it is consumed by the theme swap, at every
258/// point of the walk that makes one. See `crate::ui::scoped_theme`.
259#[derive(Copy, Clone, Debug, PartialEq)]
260struct TextStyleAnim {
261    current: TextStyle,
262    from: TextStyle,
263    to: TextStyle,
264    elapsed: f32,
265}
266
267impl TextStyleAnim {
268    fn settled(style: TextStyle) -> Self {
269        Self {
270            current: style,
271            from: style,
272            to: style,
273            elapsed: 0.0,
274        }
275    }
276}
277
278/// Timeline of an animated **size** (`Container::animated_size`): interpolates
279/// `from → to` (width/height) according to the widget's curve and duration. The
280/// interpolated size is injected **at layout** (not at paint) through `effective_style`.
281#[derive(Copy, Clone, Debug, PartialEq)]
282struct SizeAnim {
283    current: Size,
284    from: Size,
285    to: Size,
286    elapsed: f32,
287}
288
289impl SizeAnim {
290    fn settled(s: Size) -> Self {
291        Self {
292            current: s,
293            from: s,
294            to: s,
295            elapsed: 0.0,
296        }
297    }
298}
299
300/// Linear interpolation of two sizes (component-wise).
301fn lerp_size(a: Size, b: Size, t: f32) -> Size {
302    Size::new(
303        a.width + (b.width - a.width) * t,
304        a.height + (b.height - a.height) * t,
305    )
306}
307
308/// Timeline of an animated **corner radius** (`Container::animated_radius`):
309/// interpolates `from → to` (all four corners) according to the widget's curve and
310/// duration. A **paint** property: delivered to the paint through `Status::anim_radius`.
311#[derive(Copy, Clone, Debug, PartialEq)]
312struct RadiusAnim {
313    current: BorderRadius,
314    from: BorderRadius,
315    to: BorderRadius,
316    elapsed: f32,
317}
318
319impl RadiusAnim {
320    fn settled(r: BorderRadius) -> Self {
321        Self {
322            current: r,
323            from: r,
324            to: r,
325            elapsed: 0.0,
326        }
327    }
328}
329
330/// Timeline of an animated **padding** (`Container::animated_padding`): interpolates
331/// `from → to` (all four sides) according to the widget's curve and duration. The
332/// interpolated padding is injected **at layout** (`effective_style`), like the size.
333#[derive(Copy, Clone, Debug, PartialEq)]
334struct PaddingAnim {
335    current: Insets,
336    from: Insets,
337    to: Insets,
338    elapsed: f32,
339}
340
341impl PaddingAnim {
342    fn settled(p: Insets) -> Self {
343        Self {
344            current: p,
345            from: p,
346            to: p,
347            elapsed: 0.0,
348        }
349    }
350}
351
352/// Timeline of an animated **offset pair** (`Container::animated_alignment`,
353/// `FractionalTranslation::animated`): the two fractions a node's offset rule is made of,
354/// interpolated together so a child moving diagonally arrives on both axes at once.
355#[derive(Copy, Clone, Debug, PartialEq)]
356struct OffsetAnim {
357    current: (f32, f32),
358    from: (f32, f32),
359    to: (f32, f32),
360    elapsed: f32,
361}
362
363impl OffsetAnim {
364    fn settled(o: (f32, f32)) -> Self {
365        Self {
366            current: o,
367            from: o,
368            to: o,
369            elapsed: 0.0,
370        }
371    }
372}
373
374/// Timeline of a bundle of **optional** numbers a node hands to whatever lays it out —
375/// the four edges and two extents of a [`crate::Positioned`], the two factors of a
376/// [`crate::FractionallySizedBox`]. One timeline for the whole bundle, so a box that
377/// changes two of its edges arrives on both at once, the way a scale and a turn do.
378///
379/// **A slot that is unset is not a number, and so is not animated.** `None` on an edge
380/// does not mean nought there — it means the box is not pinned on that side at all, and
381/// the two are different arrangements rather than two values of one. So a slot arriving
382/// takes effect at once, a slot leaving goes at once, and only a slot set at both ends
383/// travels. The reference does exactly this and for the same reason: its tween for such
384/// a value is dropped the moment the value is null.
385#[derive(Copy, Clone, Debug, PartialEq)]
386struct SlotsAnim<const N: usize> {
387    current: [Option<f32>; N],
388    from: [Option<f32>; N],
389    to: [Option<f32>; N],
390    elapsed: f32,
391}
392
393impl<const N: usize> SlotsAnim<N> {
394    fn settled(v: [Option<f32>; N]) -> Self {
395        Self {
396            current: v,
397            from: v,
398            to: v,
399            elapsed: 0.0,
400        }
401    }
402}
403
404/// Walks the tree collecting one family of slot bundles, following the walk's own
405/// identity scheme (`child_id`) exactly — an id computed any other way is an animation
406/// the paint never finds.
407fn collect_slots<Msg, const N: usize>(
408    widget: &dyn crate::widget::Widget<Msg>,
409    id: WidgetId,
410    still: bool,
411    out: &mut Vec<(WidgetId, [Option<f32>; N], f32, Curve)>,
412    read: impl Fn(&dyn crate::widget::Widget<Msg>) -> Option<[Option<f32>; N]> + Copy,
413) {
414    if let Some(target) = read(widget) {
415        out.push((
416            id,
417            target,
418            if still {
419                0.0
420            } else {
421                widget.anim_duration().max(0.0)
422            },
423            widget.anim_curve(),
424        ));
425    }
426    for (index, child) in widget.children().iter().enumerate() {
427        collect_slots(
428            child.as_ref(),
429            crate::ui::child_id(id, index, child.as_ref()),
430            still,
431            out,
432            read,
433        );
434    }
435}
436
437/// Steps one family of slot bundles towards the targets their widgets declare. Shared by
438/// every quantity made of optional numbers, so the rule about an unset slot is written
439/// once and cannot come out differently in two places.
440fn advance_slot_family<const N: usize>(
441    map: &mut HashMap<WidgetId, SlotsAnim<N>>,
442    targets: Vec<(WidgetId, [Option<f32>; N], f32, Curve)>,
443    dt: f32,
444) -> bool {
445    let present: std::collections::HashSet<WidgetId> = targets.iter().map(|(id, ..)| *id).collect();
446    map.retain(|id, _| present.contains(id));
447
448    let mut animating = false;
449    for (id, target, duration, curve) in targets {
450        match map.entry(id) {
451            std::collections::hash_map::Entry::Occupied(mut e) => {
452                let s = e.get_mut();
453                if s.to != target {
454                    s.from = s.current;
455                    s.to = target;
456                    s.elapsed = 0.0;
457                    // A slot with nothing at one end has nowhere to travel from or to, so
458                    // it is already where it is going.
459                    for i in 0..N {
460                        if s.from[i].is_none() || s.to[i].is_none() {
461                            s.from[i] = s.to[i];
462                        }
463                    }
464                }
465                if s.from == s.to {
466                    s.current = s.to;
467                } else {
468                    s.elapsed += dt;
469                    let t = if duration > 0.0 {
470                        (s.elapsed / duration).clamp(0.0, 1.0)
471                    } else {
472                        1.0
473                    };
474                    let e = curve.transform(t);
475                    for i in 0..N {
476                        s.current[i] = match (s.from[i], s.to[i]) {
477                            (Some(a), Some(b)) => Some(a + (b - a) * e),
478                            _ => s.to[i],
479                        };
480                    }
481                    if t < 1.0 {
482                        animating = true;
483                    }
484                }
485            }
486            std::collections::hash_map::Entry::Vacant(e) => {
487                e.insert(SlotsAnim::settled(target));
488            }
489        }
490    }
491    animating
492}
493
494/// **The numbers a paint-time transform is made of** that can sensibly be interpolated:
495/// a scale on each axis and a turn.
496///
497/// The **pivot** is not here, and that is the decision worth stating: it is a choice of
498/// origin, not a quantity. Interpolating it would move a shape across the screen while
499/// every number describing the shape stayed still, which is a thing no caller ever asks
500/// for and a thing nobody would be able to debug. A widget that wants the pivot to change
501/// has changed what it is doing, not how much of it.
502#[derive(Copy, Clone, Debug, PartialEq)]
503pub struct TransformValues {
504    /// Horizontal scale; `1.0` is neutral.
505    pub scale_x: f32,
506    /// Vertical scale; `1.0` is neutral.
507    pub scale_y: f32,
508    /// Clockwise turn, in radians.
509    pub rotation: f32,
510}
511
512impl Default for TransformValues {
513    /// The identity — **not** zeroes. A scale of nought is a shape with no area, so a
514    /// transform mounting at `Default` would flash the subtree out of existence.
515    fn default() -> Self {
516        Self {
517            scale_x: 1.0,
518            scale_y: 1.0,
519            rotation: 0.0,
520        }
521    }
522}
523
524/// Timeline of an animated **transform** (`Transform::animated`): interpolates
525/// `from → to` on both scales and the turn at once, so a widget that scales and rotates
526/// together arrives on both at the same moment.
527#[derive(Copy, Clone, Debug, PartialEq)]
528struct TransformAnim {
529    current: TransformValues,
530    from: TransformValues,
531    to: TransformValues,
532    elapsed: f32,
533}
534
535impl TransformAnim {
536    fn settled(v: TransformValues) -> Self {
537        Self {
538            current: v,
539            from: v,
540            to: v,
541            elapsed: 0.0,
542        }
543    }
544}
545
546/// Linear interpolation of two transforms.
547///
548/// A scale is interpolated **linearly**, not geometrically. Halfway between 1× and 4× is
549/// 2.5× here rather than 2×, which is what the reference's own `Tween<double>` gives and
550/// what a caller reading the two numbers expects; a geometric mean would be defensible
551/// and would disagree with every other implicit animation in the framework.
552fn lerp_transform(a: TransformValues, b: TransformValues, t: f32) -> TransformValues {
553    let mix = |x: f32, y: f32| x + (y - x) * t;
554    TransformValues {
555        scale_x: mix(a.scale_x, b.scale_x),
556        scale_y: mix(a.scale_y, b.scale_y),
557        rotation: mix(a.rotation, b.rotation),
558    }
559}
560
561/// Linear interpolation of two insets (per side).
562fn lerp_insets(a: Insets, b: Insets, t: f32) -> Insets {
563    let mix = |x: f32, y: f32| x + (y - x) * t;
564    Insets::new(
565        mix(a.top, b.top),
566        mix(a.right, b.right),
567        mix(a.bottom, b.bottom),
568        mix(a.left, b.left),
569    )
570}
571
572/// Linear interpolation of two radii (per corner) — the one rule, `BorderRadius::lerp`,
573/// so that an animated container and a decoration transition cannot round differently.
574fn lerp_radius(a: BorderRadius, b: BorderRadius, t: f32) -> BorderRadius {
575    a.lerp(b, t)
576}
577
578/// One damped-spring step (semi-implicit Euler) driving `progress` towards
579/// `target`, primed by `velocity`. `stiffness`/`damping` set the stiffness and the
580/// damping (≈ `2·√stiffness` = critical damping, no overshoot).
581/// Returns `(progress, velocity, at_rest)`.
582///
583/// A general-purpose motion helper: used by screen transitions and by gestures
584/// (a settle primed by the finger's velocity).
585pub fn spring_step(
586    progress: f32,
587    velocity: f32,
588    target: f32,
589    dt: f32,
590    stiffness: f32,
591    damping: f32,
592) -> (f32, f32, bool) {
593    let accel = stiffness * (target - progress) - damping * velocity;
594    let velocity = velocity + accel * dt;
595    let progress = progress + velocity * dt;
596    let at_rest = (progress - target).abs() < 0.004 && velocity.abs() < 0.06;
597    (progress, velocity, at_rest)
598}
599
600/// A **spring** curve (the step response of a **critically** damped spring)
601/// remapping a linear progress `t ∈ [0,1]`: starts at rest (zero slope), rises
602/// briskly, arrives gently **without overshoot** — the same feel as the screen
603/// transitions, but in closed form (no velocity state). `f(0) = 0`, `f(1) = 1`,
604/// monotonically increasing.
605pub fn spring_ease(t: f32) -> f32 {
606    // The critical response (`omega = 8`), now provided by `frus-core`'s shared
607    // animation layer: a single source of truth for this curve.
608    frus_core::Curve::critical_spring().transform(t)
609}
610
611/// Drives `value` towards `target` in steps of `step`; records whether it is still moving.
612fn approach(value: &mut f32, target: f32, step: f32, animating: &mut bool) {
613    if *value < target {
614        *value = (*value + step).min(target);
615    } else if *value > target {
616        *value = (*value - step).max(target);
617    }
618    if (*value - target).abs() > 1e-3 {
619        *animating = true;
620    }
621}
622
623/// Runtime context handed to `build_ui`: all the state retained between frames.
624#[derive(Default)]
625pub struct Runtime {
626    /// What every [`Component`](crate::Component) keeps between rebuilds: the state of a
627    /// [`StatefulWidget`](crate::StatefulWidget), and the values a component's hooks hold.
628    pub states: crate::component::StateStore,
629    /// **Motion is off**: the user has asked for animations to be reduced or removed,
630    /// and the shell has passed that on from the platform.
631    ///
632    /// The **implicit** animations — the ones a widget starts by itself when a value it
633    /// was given changes — complete at once instead of over time. The change still
634    /// happens; it stops moving, which is what the setting asks for. Skipping the change
635    /// as well would leave the interface **wrong** rather than still.
636    ///
637    /// It does not touch scrolling. A fling is physics answering a finger, not a
638    /// decoration, and a list that jumped to a stop would be harder to use rather than
639    /// calmer — the reference draws the same line.
640    pub still: bool,
641    /// **How present each area's scrollbar is**, and how close a pointer has come to
642    /// it. Advanced by [`Runtime::advance_scroll`], which is where movement is seen.
643    pub scrollbar_fade: HashMap<WidgetId, ScrollbarFade>,
644    /// The area whose scrollbar a **mouse is near**, set by the shell from the previous
645    /// frame's registry. A bar that has gone comes back for a pointer that reaches for
646    /// it — otherwise a faded bar could never be grabbed again (`scrollbar.dart:2132`).
647    pub scrollbar_hovered: Option<WidgetId>,
648    /// The area whose scrollbar is **being dragged**. It stays while the thumb is held,
649    /// however long the hand pauses (`scrollbar.dart:1970`).
650    pub scrollbar_dragged: Option<WidgetId>,
651    /// Hover / press / focus.
652    pub input: InputState,
653    /// **Current** scroll offsets (the rendered ones), per region.
654    pub scroll: ScrollState,
655    /// **Target** scroll offsets (what the spring drives towards), per region.
656    pub scroll_target: ScrollState,
657    /// SingleChildScrollView velocity (for the spring), per region.
658    pub scroll_velocity: ScrollState,
659    /// The **ballistic** motion still running after a fling, per region — the
660    /// simulation the platform's physics handed us, sampled frame by frame by
661    /// [`Runtime::advance_scroll`]. Absent = no fling in flight.
662    pub scroll_ballistic: HashMap<WidgetId, ScrollBallistic>,
663    /// The overscroll glows of each region — the edge feedback a platform that
664    /// clamps needs, since it has no bounce to speak with. Absent = all quiet.
665    pub scroll_glow: HashMap<WidgetId, ScrollGlows>,
666    /// The overscroll **stretch** of each region, where the indicator is the stretch
667    /// rather than the glow (milestone 591). Absent = at rest.
668    pub scroll_stretch: HashMap<WidgetId, crate::stretch::ScrollStretch>,
669    /// The **ink** splashed on each surface that takes it, keyed by the surface. A
670    /// ripple outlives the frame that started it — a tap is 450 ms of motion — so it
671    /// is retained here rather than in the widget, which is rebuilt every frame.
672    /// Absent = dry.
673    pub ink: HashMap<WidgetId, crate::ink::Ripples>,
674    /// The retained pull of each [`crate::RefreshIndicator`] area, keyed by the area. Absent =
675    /// nothing pulled and nothing spinning.
676    pub refresh: HashMap<WidgetId, crate::refresh::RefreshPull>,
677    /// The retained swipe of each [`crate::Dismissible`] item. Absent = at rest.
678    pub dismiss: HashMap<WidgetId, crate::dismiss::DismissState>,
679    /// The retained height of each [`crate::DraggableScrollableSheet`], keyed by its
680    /// panel. Absent = never moved, and at its initial height.
681    pub sheets: HashMap<WidgetId, crate::sheet::SheetState>,
682    /// Throws that carried a sheet to full height since they were last taken: the list
683    /// each goes on in and the velocity it goes on at, in px/s. See
684    /// [`Runtime::take_sheet_handovers`].
685    pub sheet_handovers: Vec<(WidgetId, f32)>,
686    /// The page each [`crate::PageView`] was last **told to show**, so that a request
687    /// is acted on when it *changes* rather than re-asserted every frame — which
688    /// would leave the offset unswipeable. Absent = never seen, so the next request
689    /// is the initial page and arrives without an animation.
690    pub page_requested: HashMap<WidgetId, usize>,
691    /// The box each scroll region was last asked to **keep in view**, by the key the
692    /// content named it with, so that the request is acted on when it *changes* rather
693    /// than re-asserted every frame — which would pin the content and leave it
694    /// undraggable. Absent = never seen, and the walk has already opened there.
695    pub kept_visible: HashMap<WidgetId, u64>,
696    /// The page each paged view was last **reported as showing**, so that
697    /// `on_page_changed` fires on a change and not on every frame of the motion.
698    pub page_shown: HashMap<WidgetId, usize>,
699    /// The offset each scroll region was last **reported at**, so that
700    /// [`crate::Widget::on_scroll`] fires on a change and not on every frame, and so
701    /// that a region's grain is measured from what the application was actually told
702    /// rather than from the previous frame — otherwise a slow drag would never travel
703    /// a whole grain in one frame and would never be reported at all.
704    pub scroll_reported: HashMap<WidgetId, (f32, f32)>,
705    /// The drop target a drag is currently **over**, when it would accept it. The
706    /// target paints its own "drop it here" state from this, through
707    /// [`crate::interaction::Status::drag_over`] — the shell decides *which* target,
708    /// the widget decides what that looks like.
709    pub drag_over: Option<WidgetId>,
710    /// The region a finger is currently holding, if any.
711    ///
712    /// A scroll offset has **one owner at a time**. While a drag owns it, nothing
713    /// else may move it: without this, the edge spring keeps retracting the offset
714    /// between two moves of the finger, and a rubber band pulled against a bouncing
715    /// edge is dragged back as fast as it is stretched — it never appears at all.
716    /// At most one, because at most one gesture is live.
717    pub scroll_held: Option<WidgetId>,
718    /// Retained transform (scale + translation) of each
719    /// [`InteractiveViewer`](crate::InteractiveViewer), per viewport. Absent =
720    /// identity (scale 1, no translation).
721    pub interactive: HashMap<WidgetId, crate::interactive::InteractiveView>,
722    /// Pan velocity (px/s) of a *fling* still running after release, per viewport —
723    /// decelerated every frame by [`Runtime::advance_interactive`]. Absent = at rest.
724    pub interactive_velocity: HashMap<WidgetId, (f32, f32)>,
725    /// Edit state, per input field.
726    pub edits: HashMap<WidgetId, Edit>,
727    /// The field whose selection shows **handles** — one made with a finger (milestone
728    /// 511). Put away by the next press or key; shown only while that field is focused.
729    pub selection_handles: Option<WidgetId>,
730    /// The field whose **bar** — Cut, Copy, Paste, Select all — is showing over its
731    /// selection (milestone 568), with whether the clipboard held text when it opened.
732    /// Put away by the next press elsewhere or key; shown only while that field is focused.
733    pub selection_toolbar: Option<ToolbarMark>,
734    /// The selection a [`SelectionArea`](crate::SelectionArea) holds, if it holds one: kept
735    /// by the shell, read by the walk to paint the highlight. At most one — a press outside
736    /// the area, or in another, puts it away.
737    pub region: Option<crate::RegionSelection>,
738    /// The focused field's caret is in the **hidden** half of its blink (milestone 513).
739    /// Kept by the shell, on the wall clock; `false` — shown — wherever nothing blinks it,
740    /// which is every test and every picture.
741    pub caret_hidden: bool,
742    /// **Undo history**, per input field: what it was, and what it was about to be.
743    ///
744    /// Beside `edits` rather than inside it, because they are different things kept for
745    /// different reasons — a caret is where the field is now, a history is everywhere it
746    /// has been — and because `Edit` is `Copy` and a history is not.
747    pub undo: HashMap<WidgetId, crate::undo::UndoHistory>,
748    /// Animation progresses (hover/focus/opacity), per widget.
749    pub anims: HashMap<WidgetId, Anim>,
750    /// The widgets' own animated values (`Widget::anim_target`), per widget — each
751    /// one a curved **timeline** (see [`ValueAnim`]).
752    pub values: HashMap<WidgetId, ValueAnim>,
753    /// Values a drag is **holding** where the finger put them (milestone 638): not advanced
754    /// towards their target while the finger is down.
755    held_values: std::collections::HashSet<WidgetId>,
756    /// Values a drag **placed**: held, or settling from where the finger left them. They
757    /// travel linearly, in the time the distance left takes, and the paint is told so.
758    placed_values: std::collections::HashSet<WidgetId>,
759    /// Animated background colours (`Container::animated_color`), per widget.
760    colors: HashMap<WidgetId, ColorAnim>,
761    /// Animated inherited text styles (`AnimatedDefaultTextStyle`), per widget — read by
762    /// the **theme swap**, which is why this one is consumed nowhere near the others.
763    text_styles: HashMap<WidgetId, TextStyleAnim>,
764    /// Animated sizes (`Container::animated_size`), per widget — injected at layout.
765    sizes: HashMap<WidgetId, SizeAnim>,
766    /// Animated corner radii (`Container::animated_radius`), per widget.
767    radii: HashMap<WidgetId, RadiusAnim>,
768    /// Animated paddings (`Container::animated_padding`), per widget — injected at layout.
769    paddings: HashMap<WidgetId, PaddingAnim>,
770    /// Animated offset pairs (an alignment, a slide), per widget — read at paint.
771    offsets: HashMap<WidgetId, OffsetAnim>,
772    /// Animated transforms (`Transform::animated`), per widget — read at paint.
773    transforms: HashMap<WidgetId, TransformAnim>,
774    /// Animated stack pins (`AnimatedPositioned`), per widget — read where the stack
775    /// lays its layers out. Six slots: left, top, right, bottom, width, height.
776    pins: HashMap<WidgetId, SlotsAnim<8>>,
777    /// Animated fractions of the parent (`FractionallySizedBox::animated`), per widget —
778    /// injected at layout. Two slots: the width factor and the height factor.
779    fractions: HashMap<WidgetId, SlotsAnim<2>>,
780    /// Widgets present at the previous frame (to detect mounts).
781    pub mounted: std::collections::HashSet<WidgetId>,
782    /// Snapshots of outgoing subtrees, fading out: event key → (captured
783    /// primitives, remaining opacity `1 → 0`).
784    pub leaving: HashMap<u64, (Vec<Primitive>, f32)>,
785    /// What each [`AnimatedSwitcher`](crate::AnimatedSwitcher) is showing and letting go
786    /// of, per widget. Interior mutability, like the caches below: a switcher meets a new
787    /// value while the tree is being **built**, which is the only time it has one.
788    pub(crate) switchers: RefCell<HashMap<WidgetId, crate::switcher::Switch>>,
789    /// Time elapsed (seconds) since start-up, for continuous animations.
790    pub time: f32,
791    /// Was the last interaction a **keyboard** one? The generic focus ring is only
792    /// painted in that case (`FocusHighlightMode`: a click must not flash a ring).
793    /// The focus itself stays active.
794    pub focus_visible: bool,
795    /// Relayout-boundary cache (rects retained per layout root, from one frame to
796    /// the next). Interior mutability: `build_ui` updates it while holding only a
797    /// shared reference to the `Runtime`.
798    pub layout_cache: RefCell<LayoutCache>,
799    /// **Repaint**-boundary cache (primitives + interactions retained per boundary,
800    /// from one frame to the next). Same interior mutability.
801    pub paint_cache: RefCell<crate::paintcache::PaintCache>,
802    /// Where each [`crate::AnimatedSize`] box is on its way from one size to the next
803    /// (milestone 594). Behind a `RefCell` for the same reason as the layout cache: the
804    /// layout sees a child's new size, and it holds only a shared reference.
805    pub(crate) size_anims: RefCell<crate::animatedsize::SizeAnims>,
806}
807
808impl Runtime {
809    /// A widget's animated hover progress.
810    pub fn hover_progress(&self, id: WidgetId) -> f32 {
811        self.anims.get(&id).map(|a| a.hover).unwrap_or(0.0)
812    }
813
814    /// A widget's animated focus progress.
815    pub fn focus_progress(&self, id: WidgetId) -> f32 {
816        self.anims.get(&id).map(|a| a.focus).unwrap_or(0.0)
817    }
818
819    /// A widget's animated **press** progress, or `default` where the runtime has never
820    /// heard of it.
821    ///
822    /// The fallback is the rule [`Runtime::value_or`] applies: a frame built before the
823    /// loop has advanced anything — a test, an isolated render — must draw a widget that
824    /// *is* being held as held, not as untouched on its way there.
825    pub fn press_progress_or(&self, id: WidgetId, default: f32) -> f32 {
826        self.anims.get(&id).map(|a| a.press).unwrap_or(default)
827    }
828
829    /// A widget's animated press progress (0 by default).
830    pub fn press_progress(&self, id: WidgetId) -> f32 {
831        self.press_progress_or(id, 0.0)
832    }
833
834    /// A widget's animated opacity (1 by default).
835    pub fn opacity(&self, id: WidgetId) -> f32 {
836        self.anims.get(&id).map(|a| a.opacity).unwrap_or(1.0)
837    }
838
839    /// A widget's animated value (0 by default).
840    pub fn value(&self, id: WidgetId) -> f32 {
841        self.values.get(&id).map(|v| v.current).unwrap_or(0.0)
842    }
843
844    /// A widget's animated value, or `default` if **no** value has been recorded yet
845    /// (a widget never animated — e.g. an isolated render with no loop). Lets the
846    /// target be adopted immediately, as on mount.
847    pub fn value_or(&self, id: WidgetId, default: f32) -> f32 {
848        self.values.get(&id).map(|v| v.current).unwrap_or(default)
849    }
850
851    /// Sets a widget's animated value to `v` (at rest, no transition in progress) —
852    /// for isolated renders/tests that want a precise progress without running the
853    /// animation.
854    pub fn set_value(&mut self, id: WidgetId, v: f32) {
855        self.values.insert(id, ValueAnim::settled(v));
856    }
857
858    /// **Holds** a widget's value at `v` under a finger (milestone 638): it stays there,
859    /// whatever its target, until [`release_value`](Self::release_value).
860    pub fn hold_value(&mut self, id: WidgetId, v: f32) {
861        self.values.insert(id, ValueAnim::settled(v));
862        self.held_values.insert(id);
863        self.placed_values.insert(id);
864    }
865
866    /// Lets go of a held value: it settles from where it was left to its target, linearly
867    /// and in the share of the widget's duration the distance left is — as a controller
868    /// let go part way finishes the way it was going.
869    pub fn release_value(&mut self, id: WidgetId) {
870        self.held_values.remove(&id);
871    }
872
873    /// Whether a drag placed this value and it has not settled yet.
874    pub fn value_placed(&self, id: WidgetId) -> bool {
875        self.placed_values.contains(&id)
876    }
877
878    /// Drives every animated value towards the target its widget declares
879    /// (`Widget::anim_target`). A widget seen for the **first** time adopts its
880    /// target with no transition (no animation on mount). Returns `true` if a value
881    /// is still moving.
882    pub fn advance_values<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
883        fn collect<Msg>(
884            widget: &dyn crate::widget::Widget<Msg>,
885            id: WidgetId,
886            still: bool,
887            out: &mut Vec<(WidgetId, f32, f32, Curve)>,
888        ) {
889            if let Some(target) = widget.anim_target() {
890                out.push((
891                    id,
892                    target,
893                    if still {
894                        0.0
895                    } else {
896                        widget.anim_duration().max(0.0)
897                    },
898                    widget.anim_curve(),
899                ));
900            }
901            for (index, child) in widget.children().iter().enumerate() {
902                collect(
903                    child.as_ref(),
904                    crate::ui::child_id(id, index, child.as_ref()),
905                    still,
906                    out,
907                );
908            }
909        }
910        let mut targets: Vec<(WidgetId, f32, f32, Curve)> = Vec::new();
911        collect(root, WidgetId::ROOT, self.still, &mut targets);
912
913        // Forget the values of widgets that have gone.
914        let present: std::collections::HashSet<WidgetId> =
915            targets.iter().map(|(id, ..)| *id).collect();
916        self.values.retain(|id, _| present.contains(id));
917        self.held_values.retain(|id| present.contains(id));
918        self.placed_values.retain(|id| present.contains(id));
919
920        let mut animating = false;
921        for (id, target, duration, curve) in targets {
922            // A finger holds it: nothing moves it until it lets go.
923            if self.held_values.contains(&id) {
924                continue;
925            }
926            let placed = self.placed_values.contains(&id);
927            match self.values.entry(id) {
928                std::collections::hash_map::Entry::Occupied(mut e) => {
929                    let v = e.get_mut();
930                    // Placed by a finger: straight there, in the time the distance takes.
931                    let (duration, curve) = if placed {
932                        let distance = if v.to != target {
933                            (target - v.current).abs()
934                        } else {
935                            (v.to - v.from).abs()
936                        };
937                        (duration * distance.min(1.0), Curve::Linear)
938                    } else {
939                        (duration, curve)
940                    };
941                    // New target: rebase the timeline from the current value.
942                    if v.to != target {
943                        v.from = v.current;
944                        v.to = target;
945                        v.elapsed = 0.0;
946                    }
947                    if v.from == v.to {
948                        v.current = v.to;
949                    } else {
950                        v.elapsed += dt;
951                        let t = if duration > 0.0 {
952                            (v.elapsed / duration).clamp(0.0, 1.0)
953                        } else {
954                            1.0
955                        };
956                        v.current = v.from + (v.to - v.from) * curve.transform(t);
957                        if t < 1.0 {
958                            animating = true;
959                        }
960                    }
961                    if placed && v.current == v.to {
962                        self.placed_values.remove(&id);
963                    }
964                }
965                std::collections::hash_map::Entry::Vacant(e) => {
966                    // Mount: adopts the target with no transition.
967                    e.insert(ValueAnim::settled(target));
968                }
969            }
970        }
971        animating
972    }
973
974    /// A widget's animated background colour, if in transition (`None` otherwise).
975    pub fn anim_color(&self, id: WidgetId) -> Option<Color> {
976        self.colors.get(&id).map(|c| c.current)
977    }
978
979    /// Drives every animated background colour towards the target its widget
980    /// declares (`Widget::anim_color`), following its duration/curve
981    /// (`anim_duration`/`anim_curve`). On mount: adopts the target with no
982    /// transition. Returns `true` if a colour is still moving. Same model as
983    /// [`Self::advance_values`].
984    pub fn advance_colors<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
985        fn collect<Msg>(
986            widget: &dyn crate::widget::Widget<Msg>,
987            id: WidgetId,
988            still: bool,
989            out: &mut Vec<(WidgetId, Color, f32, Curve)>,
990        ) {
991            if let Some(target) = widget.anim_color() {
992                out.push((
993                    id,
994                    target,
995                    if still {
996                        0.0
997                    } else {
998                        widget.anim_duration().max(0.0)
999                    },
1000                    widget.anim_curve(),
1001                ));
1002            }
1003            for (index, child) in widget.children().iter().enumerate() {
1004                collect(
1005                    child.as_ref(),
1006                    crate::ui::child_id(id, index, child.as_ref()),
1007                    still,
1008                    out,
1009                );
1010            }
1011        }
1012        let mut targets: Vec<(WidgetId, Color, f32, Curve)> = Vec::new();
1013        collect(root, WidgetId::ROOT, self.still, &mut targets);
1014
1015        let present: std::collections::HashSet<WidgetId> =
1016            targets.iter().map(|(id, ..)| *id).collect();
1017        self.colors.retain(|id, _| present.contains(id));
1018
1019        let mut animating = false;
1020        for (id, target, duration, curve) in targets {
1021            match self.colors.entry(id) {
1022                std::collections::hash_map::Entry::Occupied(mut e) => {
1023                    let c = e.get_mut();
1024                    if c.to != target {
1025                        c.from = c.current;
1026                        c.to = target;
1027                        c.elapsed = 0.0;
1028                    }
1029                    if c.from == c.to {
1030                        c.current = c.to;
1031                    } else {
1032                        c.elapsed += dt;
1033                        let t = if duration > 0.0 {
1034                            (c.elapsed / duration).clamp(0.0, 1.0)
1035                        } else {
1036                            1.0
1037                        };
1038                        c.current = c.from.lerp(c.to, curve.transform(t));
1039                        if t < 1.0 {
1040                            animating = true;
1041                        }
1042                    }
1043                }
1044                std::collections::hash_map::Entry::Vacant(e) => {
1045                    e.insert(ColorAnim::settled(target));
1046                }
1047            }
1048        }
1049        animating
1050    }
1051
1052    /// A widget's animated inherited text style, if in transition (`None` otherwise).
1053    ///
1054    /// Read by the theme swap rather than by the node itself: see
1055    /// `crate::ui::scoped_theme`, which is the one place the value is consumed and the
1056    /// reason there is one place at all.
1057    pub fn anim_text_style(&self, id: WidgetId) -> Option<TextStyle> {
1058        self.text_styles.get(&id).map(|s| s.current)
1059    }
1060
1061    /// Drives every animated inherited text style towards the target its widget declares
1062    /// (`Widget::anim_text_style`), following its duration/curve. On mount: adopts the
1063    /// target with no transition. Returns `true` if a style is still moving.
1064    ///
1065    /// Same model as [`Self::advance_colors`], over [`TextStyle::lerp`] — which is where
1066    /// the rules live for what travels, what holds and what swaps at the halfway point.
1067    pub fn advance_text_styles<Msg>(
1068        &mut self,
1069        root: &dyn crate::widget::Widget<Msg>,
1070        dt: f32,
1071    ) -> bool {
1072        fn collect<Msg>(
1073            widget: &dyn crate::widget::Widget<Msg>,
1074            id: WidgetId,
1075            still: bool,
1076            out: &mut Vec<(WidgetId, TextStyle, f32, Curve)>,
1077        ) {
1078            if let Some(target) = widget.anim_text_style() {
1079                out.push((
1080                    id,
1081                    target,
1082                    if still {
1083                        0.0
1084                    } else {
1085                        widget.anim_duration().max(0.0)
1086                    },
1087                    widget.anim_curve(),
1088                ));
1089            }
1090            for (index, child) in widget.children().iter().enumerate() {
1091                collect(
1092                    child.as_ref(),
1093                    crate::ui::child_id(id, index, child.as_ref()),
1094                    still,
1095                    out,
1096                );
1097            }
1098        }
1099        let mut targets: Vec<(WidgetId, TextStyle, f32, Curve)> = Vec::new();
1100        collect(root, WidgetId::ROOT, self.still, &mut targets);
1101
1102        let present: std::collections::HashSet<WidgetId> =
1103            targets.iter().map(|(id, ..)| *id).collect();
1104        self.text_styles.retain(|id, _| present.contains(id));
1105
1106        let mut animating = false;
1107        for (id, target, duration, curve) in targets {
1108            match self.text_styles.entry(id) {
1109                std::collections::hash_map::Entry::Occupied(mut e) => {
1110                    let s = e.get_mut();
1111                    if s.to != target {
1112                        s.from = s.current;
1113                        s.to = target;
1114                        s.elapsed = 0.0;
1115                    }
1116                    if s.from == s.to {
1117                        s.current = s.to;
1118                    } else {
1119                        s.elapsed += dt;
1120                        let t = if duration > 0.0 {
1121                            (s.elapsed / duration).clamp(0.0, 1.0)
1122                        } else {
1123                            1.0
1124                        };
1125                        s.current = s.from.lerp(s.to, curve.transform(t));
1126                        if t < 1.0 {
1127                            animating = true;
1128                        }
1129                    }
1130                }
1131                std::collections::hash_map::Entry::Vacant(e) => {
1132                    e.insert(TextStyleAnim::settled(target));
1133                }
1134            }
1135        }
1136        animating
1137    }
1138
1139    /// A widget's animated size, if in transition (`None` otherwise).
1140    pub fn anim_size(&self, id: WidgetId) -> Option<Size> {
1141        self.sizes.get(&id).map(|s| s.current)
1142    }
1143
1144    /// Drives every animated size towards the target its widget declares
1145    /// (`Widget::anim_size`), following its duration/curve. On mount: adopts the
1146    /// target with no transition. Returns `true` if a size is still moving. Same
1147    /// model as [`Self::advance_values`], except the output is **consumed at
1148    /// layout** (`effective_style`), not at paint.
1149    pub fn advance_sizes<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1150        fn collect<Msg>(
1151            widget: &dyn crate::widget::Widget<Msg>,
1152            id: WidgetId,
1153            still: bool,
1154            out: &mut Vec<(WidgetId, Size, f32, Curve)>,
1155        ) {
1156            if let Some(target) = widget.anim_size() {
1157                out.push((
1158                    id,
1159                    target,
1160                    if still {
1161                        0.0
1162                    } else {
1163                        widget.anim_duration().max(0.0)
1164                    },
1165                    widget.anim_curve(),
1166                ));
1167            }
1168            for (index, child) in widget.children().iter().enumerate() {
1169                collect(
1170                    child.as_ref(),
1171                    crate::ui::child_id(id, index, child.as_ref()),
1172                    still,
1173                    out,
1174                );
1175            }
1176        }
1177        let mut targets: Vec<(WidgetId, Size, f32, Curve)> = Vec::new();
1178        collect(root, WidgetId::ROOT, self.still, &mut targets);
1179
1180        let present: std::collections::HashSet<WidgetId> =
1181            targets.iter().map(|(id, ..)| *id).collect();
1182        self.sizes.retain(|id, _| present.contains(id));
1183
1184        let mut animating = false;
1185        for (id, target, duration, curve) in targets {
1186            match self.sizes.entry(id) {
1187                std::collections::hash_map::Entry::Occupied(mut e) => {
1188                    let s = e.get_mut();
1189                    if s.to != target {
1190                        s.from = s.current;
1191                        s.to = target;
1192                        s.elapsed = 0.0;
1193                    }
1194                    if s.from == s.to {
1195                        s.current = s.to;
1196                    } else {
1197                        s.elapsed += dt;
1198                        let t = if duration > 0.0 {
1199                            (s.elapsed / duration).clamp(0.0, 1.0)
1200                        } else {
1201                            1.0
1202                        };
1203                        s.current = lerp_size(s.from, s.to, curve.transform(t));
1204                        if t < 1.0 {
1205                            animating = true;
1206                        }
1207                    }
1208                }
1209                std::collections::hash_map::Entry::Vacant(e) => {
1210                    e.insert(SizeAnim::settled(target));
1211                }
1212            }
1213        }
1214        animating
1215    }
1216
1217    /// A widget's animated corner radius, if in transition (`None` otherwise).
1218    pub fn anim_radius(&self, id: WidgetId) -> Option<BorderRadius> {
1219        self.radii.get(&id).map(|r| r.current)
1220    }
1221
1222    /// Drives every animated corner radius towards the target its widget declares
1223    /// (`Widget::anim_radius`), following its duration/curve. On mount: adopts the
1224    /// target with no transition. Returns `true` if a radius is still moving. Same
1225    /// model as [`Self::advance_colors`], applied to a [`BorderRadius`].
1226    pub fn advance_radii<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1227        fn collect<Msg>(
1228            widget: &dyn crate::widget::Widget<Msg>,
1229            id: WidgetId,
1230            still: bool,
1231            out: &mut Vec<(WidgetId, BorderRadius, f32, Curve)>,
1232        ) {
1233            if let Some(target) = widget.anim_radius() {
1234                out.push((
1235                    id,
1236                    target,
1237                    if still {
1238                        0.0
1239                    } else {
1240                        widget.anim_duration().max(0.0)
1241                    },
1242                    widget.anim_curve(),
1243                ));
1244            }
1245            for (index, child) in widget.children().iter().enumerate() {
1246                collect(
1247                    child.as_ref(),
1248                    crate::ui::child_id(id, index, child.as_ref()),
1249                    still,
1250                    out,
1251                );
1252            }
1253        }
1254        let mut targets: Vec<(WidgetId, BorderRadius, f32, Curve)> = Vec::new();
1255        collect(root, WidgetId::ROOT, self.still, &mut targets);
1256
1257        let present: std::collections::HashSet<WidgetId> =
1258            targets.iter().map(|(id, ..)| *id).collect();
1259        self.radii.retain(|id, _| present.contains(id));
1260
1261        let mut animating = false;
1262        for (id, target, duration, curve) in targets {
1263            match self.radii.entry(id) {
1264                std::collections::hash_map::Entry::Occupied(mut e) => {
1265                    let r = e.get_mut();
1266                    if r.to != target {
1267                        r.from = r.current;
1268                        r.to = target;
1269                        r.elapsed = 0.0;
1270                    }
1271                    if r.from == r.to {
1272                        r.current = r.to;
1273                    } else {
1274                        r.elapsed += dt;
1275                        let t = if duration > 0.0 {
1276                            (r.elapsed / duration).clamp(0.0, 1.0)
1277                        } else {
1278                            1.0
1279                        };
1280                        r.current = lerp_radius(r.from, r.to, curve.transform(t));
1281                        if t < 1.0 {
1282                            animating = true;
1283                        }
1284                    }
1285                }
1286                std::collections::hash_map::Entry::Vacant(e) => {
1287                    e.insert(RadiusAnim::settled(target));
1288                }
1289            }
1290        }
1291        animating
1292    }
1293
1294    /// A widget's animated transform, if in transition (`None` otherwise).
1295    pub fn anim_transform(&self, id: WidgetId) -> Option<TransformValues> {
1296        self.transforms.get(&id).map(|t| t.current)
1297    }
1298
1299    /// Drives every animated transform towards the target its widget declares
1300    /// (`Widget::anim_transform`), following its duration/curve. On mount: adopts the
1301    /// target with no transition — a widget that appears already scaled does not scale
1302    /// in from nothing. Returns `true` if a transform is still moving. The output is
1303    /// consumed **at paint**, unlike the size and the padding.
1304    pub fn advance_transforms<Msg>(
1305        &mut self,
1306        root: &dyn crate::widget::Widget<Msg>,
1307        dt: f32,
1308    ) -> bool {
1309        fn collect<Msg>(
1310            widget: &dyn crate::widget::Widget<Msg>,
1311            id: WidgetId,
1312            still: bool,
1313            out: &mut Vec<(WidgetId, TransformValues, f32, Curve)>,
1314        ) {
1315            if let Some(target) = widget.anim_transform() {
1316                out.push((
1317                    id,
1318                    target,
1319                    if still {
1320                        0.0
1321                    } else {
1322                        widget.anim_duration().max(0.0)
1323                    },
1324                    widget.anim_curve(),
1325                ));
1326            }
1327            for (index, child) in widget.children().iter().enumerate() {
1328                collect(
1329                    child.as_ref(),
1330                    crate::ui::child_id(id, index, child.as_ref()),
1331                    still,
1332                    out,
1333                );
1334            }
1335        }
1336        let mut targets: Vec<(WidgetId, TransformValues, f32, Curve)> = Vec::new();
1337        collect(root, WidgetId::ROOT, self.still, &mut targets);
1338
1339        let present: std::collections::HashSet<WidgetId> =
1340            targets.iter().map(|(id, ..)| *id).collect();
1341        self.transforms.retain(|id, _| present.contains(id));
1342
1343        let mut animating = false;
1344        for (id, target, duration, curve) in targets {
1345            match self.transforms.entry(id) {
1346                std::collections::hash_map::Entry::Occupied(mut e) => {
1347                    let v = e.get_mut();
1348                    if v.to != target {
1349                        v.from = v.current;
1350                        v.to = target;
1351                        v.elapsed = 0.0;
1352                    }
1353                    if v.from == v.to {
1354                        v.current = v.to;
1355                    } else {
1356                        v.elapsed += dt;
1357                        let t = if duration > 0.0 {
1358                            (v.elapsed / duration).clamp(0.0, 1.0)
1359                        } else {
1360                            1.0
1361                        };
1362                        v.current = lerp_transform(v.from, v.to, curve.transform(t));
1363                        if t < 1.0 {
1364                            animating = true;
1365                        }
1366                    }
1367                }
1368                std::collections::hash_map::Entry::Vacant(e) => {
1369                    e.insert(TransformAnim::settled(target));
1370                }
1371            }
1372        }
1373        animating
1374    }
1375
1376    /// A widget's animated padding, if in transition (`None` otherwise).
1377    pub fn anim_padding(&self, id: WidgetId) -> Option<Insets> {
1378        self.paddings.get(&id).map(|p| p.current)
1379    }
1380
1381    /// A widget's animated offset pair, if in transition (`None` otherwise).
1382    pub fn anim_offset(&self, id: WidgetId) -> Option<(f32, f32)> {
1383        self.offsets.get(&id).map(|o| o.current)
1384    }
1385
1386    /// Drives every animated offset pair towards the target its widget declares
1387    /// (`Widget::anim_offset`), following its duration/curve. On mount: adopts the target
1388    /// with no transition. Returns `true` if one is still moving.
1389    ///
1390    /// The pair belongs to whichever **offset rule** the node declares — its alignment,
1391    /// or its slide — which is why one quantity serves both: a node has one of those
1392    /// rules, and the wrappers in `animated.rs` each give it a node of its own.
1393    pub fn advance_offsets<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1394        fn collect<Msg>(
1395            widget: &dyn crate::widget::Widget<Msg>,
1396            id: WidgetId,
1397            still: bool,
1398            out: &mut Vec<(WidgetId, (f32, f32), f32, Curve)>,
1399        ) {
1400            if let Some(target) = widget.anim_offset() {
1401                out.push((
1402                    id,
1403                    target,
1404                    if still {
1405                        0.0
1406                    } else {
1407                        widget.anim_duration().max(0.0)
1408                    },
1409                    widget.anim_curve(),
1410                ));
1411            }
1412            for (index, child) in widget.children().iter().enumerate() {
1413                collect(
1414                    child.as_ref(),
1415                    crate::ui::child_id(id, index, child.as_ref()),
1416                    still,
1417                    out,
1418                );
1419            }
1420        }
1421        let mut targets: Vec<(WidgetId, (f32, f32), f32, Curve)> = Vec::new();
1422        collect(root, WidgetId::ROOT, self.still, &mut targets);
1423
1424        let present: std::collections::HashSet<WidgetId> =
1425            targets.iter().map(|(id, ..)| *id).collect();
1426        self.offsets.retain(|id, _| present.contains(id));
1427
1428        let mut animating = false;
1429        for (id, target, duration, curve) in targets {
1430            match self.offsets.entry(id) {
1431                std::collections::hash_map::Entry::Occupied(mut e) => {
1432                    let o = e.get_mut();
1433                    if o.to != target {
1434                        o.from = o.current;
1435                        o.to = target;
1436                        o.elapsed = 0.0;
1437                    }
1438                    if o.from == o.to {
1439                        o.current = o.to;
1440                    } else {
1441                        o.elapsed += dt;
1442                        let t = if duration > 0.0 {
1443                            (o.elapsed / duration).clamp(0.0, 1.0)
1444                        } else {
1445                            1.0
1446                        };
1447                        let e = curve.transform(t);
1448                        o.current = (
1449                            o.from.0 + (o.to.0 - o.from.0) * e,
1450                            o.from.1 + (o.to.1 - o.from.1) * e,
1451                        );
1452                        if t < 1.0 {
1453                            animating = true;
1454                        }
1455                    }
1456                }
1457                std::collections::hash_map::Entry::Vacant(e) => {
1458                    e.insert(OffsetAnim::settled(target));
1459                }
1460            }
1461        }
1462        animating
1463    }
1464
1465    /// Drives every animated padding towards the target its widget declares
1466    /// (`Widget::anim_padding`), following its duration/curve. On mount: adopts the
1467    /// target with no transition. Returns `true` if a padding is still moving. Like
1468    /// the size, the output is **consumed at layout** (`effective_style`).
1469    pub fn advance_paddings<Msg>(
1470        &mut self,
1471        root: &dyn crate::widget::Widget<Msg>,
1472        dt: f32,
1473    ) -> bool {
1474        fn collect<Msg>(
1475            widget: &dyn crate::widget::Widget<Msg>,
1476            id: WidgetId,
1477            still: bool,
1478            out: &mut Vec<(WidgetId, Insets, f32, Curve)>,
1479        ) {
1480            if let Some(target) = widget.anim_padding() {
1481                out.push((
1482                    id,
1483                    target,
1484                    if still {
1485                        0.0
1486                    } else {
1487                        widget.anim_duration().max(0.0)
1488                    },
1489                    widget.anim_curve(),
1490                ));
1491            }
1492            for (index, child) in widget.children().iter().enumerate() {
1493                collect(
1494                    child.as_ref(),
1495                    crate::ui::child_id(id, index, child.as_ref()),
1496                    still,
1497                    out,
1498                );
1499            }
1500        }
1501        let mut targets: Vec<(WidgetId, Insets, f32, Curve)> = Vec::new();
1502        collect(root, WidgetId::ROOT, self.still, &mut targets);
1503
1504        let present: std::collections::HashSet<WidgetId> =
1505            targets.iter().map(|(id, ..)| *id).collect();
1506        self.paddings.retain(|id, _| present.contains(id));
1507
1508        let mut animating = false;
1509        for (id, target, duration, curve) in targets {
1510            match self.paddings.entry(id) {
1511                std::collections::hash_map::Entry::Occupied(mut e) => {
1512                    let p = e.get_mut();
1513                    if p.to != target {
1514                        p.from = p.current;
1515                        p.to = target;
1516                        p.elapsed = 0.0;
1517                    }
1518                    if p.from == p.to {
1519                        p.current = p.to;
1520                    } else {
1521                        p.elapsed += dt;
1522                        let t = if duration > 0.0 {
1523                            (p.elapsed / duration).clamp(0.0, 1.0)
1524                        } else {
1525                            1.0
1526                        };
1527                        p.current = lerp_insets(p.from, p.to, curve.transform(t));
1528                        if t < 1.0 {
1529                            animating = true;
1530                        }
1531                    }
1532                }
1533                std::collections::hash_map::Entry::Vacant(e) => {
1534                    e.insert(PaddingAnim::settled(target));
1535                }
1536            }
1537        }
1538        animating
1539    }
1540
1541    /// A widget's animated stack pins, if it has any (`None` otherwise).
1542    pub fn anim_pins(&self, id: WidgetId) -> Option<crate::positioned::Positioning> {
1543        self.pins.get(&id).map(|p| {
1544            let [left, top, right, bottom, width, height, start, end] = p.current;
1545            crate::positioned::Positioning {
1546                left,
1547                top,
1548                right,
1549                bottom,
1550                width,
1551                height,
1552                start,
1553                end,
1554            }
1555        })
1556    }
1557
1558    /// A widget's animated fractions of its parent, if it has any (`None` otherwise), as
1559    /// `(width factor, height factor)`.
1560    pub fn anim_fractions(&self, id: WidgetId) -> Option<(Option<f32>, Option<f32>)> {
1561        self.fractions
1562            .get(&id)
1563            .map(|f| (f.current[0], f.current[1]))
1564    }
1565
1566    /// Drives every animated set of stack pins towards the target its widget declares
1567    /// (`Widget::anim_pins`), following its duration/curve. On mount: adopts the target
1568    /// with no transition. Returns `true` if a layer is still moving.
1569    ///
1570    /// The output is consumed where the **stack** lays its layers out, which is the only
1571    /// place pins mean anything — a pin is a request to a parent of one particular kind,
1572    /// not a property of the box.
1573    pub fn advance_pins<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1574        let mut targets = Vec::new();
1575        collect_slots(root, WidgetId::ROOT, self.still, &mut targets, |w| {
1576            w.anim_pins().map(|p| {
1577                [
1578                    p.left, p.top, p.right, p.bottom, p.width, p.height, p.start, p.end,
1579                ]
1580            })
1581        });
1582        advance_slot_family(&mut self.pins, targets, dt)
1583    }
1584
1585    /// Drives every animated pair of fractions towards the target its widget declares
1586    /// (`Widget::anim_fractions`), following its duration/curve. On mount: adopts the
1587    /// target with no transition. Returns `true` if a box is still growing or shrinking.
1588    ///
1589    /// Like the size and the padding, the output is **consumed at layout**
1590    /// (`effective_style`): a share of the parent is a claim on room, and a claim on room
1591    /// is settled before anything is drawn.
1592    pub fn advance_fractions<Msg>(
1593        &mut self,
1594        root: &dyn crate::widget::Widget<Msg>,
1595        dt: f32,
1596    ) -> bool {
1597        let mut targets = Vec::new();
1598        collect_slots(root, WidgetId::ROOT, self.still, &mut targets, |w| {
1599            w.anim_fractions().map(|(width, height)| [width, height])
1600        });
1601        advance_slot_family(&mut self.fractions, targets, dt)
1602    }
1603
1604    /// Advances the transitions (hover/focus/press) by `dt` seconds towards their
1605    /// targets. Returns `true` if at least one animation is still running.
1606    pub fn advance(&mut self, dt: f32) -> bool {
1607        let hovered = self.input.hovered;
1608        let focused = self.input.focused;
1609        // Held **and** still under the pointer, which is what makes it a press: the same
1610        // rule `InputState::status_for` applies, so the progression and the flag can
1611        // never disagree about what is happening.
1612        let held = self.input.pressed.filter(|id| Some(*id) == hovered);
1613        if let Some(id) = hovered {
1614            self.anims.entry(id).or_default();
1615        }
1616        if let Some(id) = focused {
1617            self.anims.entry(id).or_default();
1618        }
1619        if let Some(id) = held {
1620            self.anims.entry(id).or_default();
1621        }
1622
1623        let step = if ANIM_DURATION > 0.0 {
1624            dt / ANIM_DURATION
1625        } else {
1626            1.0
1627        };
1628        // The press runs on its own, slower clock (`ink_well.dart:995`).
1629        let press_step = if PRESS_DURATION > 0.0 {
1630            dt / PRESS_DURATION
1631        } else {
1632            1.0
1633        };
1634        let mut animating = false;
1635
1636        self.anims.retain(|id, anim| {
1637            let hover_target = if Some(*id) == hovered { 1.0 } else { 0.0 };
1638            let focus_target = if Some(*id) == focused { 1.0 } else { 0.0 };
1639            let press_target = if Some(*id) == held { 1.0 } else { 0.0 };
1640            approach(&mut anim.hover, hover_target, step, &mut animating);
1641            approach(&mut anim.focus, focus_target, step, &mut animating);
1642            approach(&mut anim.press, press_target, press_step, &mut animating);
1643            // Opacity always tends towards 1 (the fade-in).
1644            approach(&mut anim.opacity, 1.0, step, &mut animating);
1645            // Entries that are entirely at rest are forgotten (nothing to animate). A
1646            // press still fading **out** is not at rest: forgetting it here would be the
1647            // jump this progression exists to remove, on the way back.
1648            !(hover_target == 0.0
1649                && focus_target == 0.0
1650                && press_target == 0.0
1651                && anim.hover <= 0.0
1652                && anim.focus <= 0.0
1653                && anim.press <= 0.0
1654                && anim.opacity >= 1.0)
1655        });
1656        // The boxes following their child's size (milestone 594), here so that everything
1657        // that advances the runtime's transitions advances them too.
1658        animating |= self.size_anims.get_mut().advance(dt, self.still);
1659
1660        animating
1661    }
1662
1663    /// Advances every scroll region by `dt`, and returns `true` if any is still
1664    /// moving.
1665    ///
1666    /// Two motions live here, and they are not the same thing:
1667    ///
1668    /// - a **fling**, when the finger let go with speed: the region's physics built
1669    ///   a simulation ([`ScrollBallistic`]), and we sample it. This is where the
1670    ///   platform's feel lives — spline deceleration that stops at the edge, or
1671    ///   friction that hands over to a spring and bounces.
1672    /// - a **glide to a target**, for the discrete inputs (the wheel, a programmatic
1673    ///   scroll): a spring eases the offset across to where it was asked to go.
1674    ///
1675    /// A fling wins while it runs: it drives the offset directly and keeps the
1676    /// target in step, so the spring has nothing to pull against.
1677    ///
1678    /// `regions` describes the scrollables of the last frame, each with the physics its
1679    /// scroll behaviour settled on when it was registered (milestone 616).
1680    pub fn advance_scroll(&mut self, regions: &[Scrollable], dt: f32) -> bool {
1681        // Content that shrank under an offset nobody owns is brought back first, so that
1682        // everything below starts from where the content now is (milestone 533).
1683        self.keep_scroll_in_range(regions);
1684        let mut animating = false;
1685        let ballistic_ids: Vec<WidgetId> = self.scroll_ballistic.keys().copied().collect();
1686        for id in ballistic_ids {
1687            if self.advance_ballistic(id, regions, dt) {
1688                animating = true;
1689            }
1690        }
1691
1692        let ids: Vec<WidgetId> = self.scroll_target.keys().copied().collect();
1693        for id in ids {
1694            // Under a fling this region's offset is already settled for the frame.
1695            if self.scroll_ballistic.contains_key(&id) {
1696                continue;
1697            }
1698            // Under a finger it belongs to the finger, full stop.
1699            if self.scroll_held == Some(id) {
1700                continue;
1701            }
1702            let area = regions.iter().find(|area| area.id == id);
1703            let (max_x, max_y) = area.map(|a| (a.max_x, a.max_y)).unwrap_or((0.0, 0.0));
1704            let current = self.scroll.get(&id).copied().unwrap_or((0.0, 0.0));
1705            let target = self.scroll_target.get(&id).copied().unwrap_or(current);
1706            let vel = self.scroll_velocity.get(&id).copied().unwrap_or((0.0, 0.0));
1707
1708            let (cx, vx, tx, ax) = scroll_axis(current.0, vel.0, target.0, max_x, dt);
1709            let (cy, vy, ty, ay) = scroll_axis(current.1, vel.1, target.1, max_y, dt);
1710
1711            self.scroll.insert(id, (cx, cy));
1712            if ax || ay {
1713                self.scroll_target.insert(id, (tx, ty));
1714                self.scroll_velocity.insert(id, (vx, vy));
1715                animating = true;
1716            } else {
1717                // At rest: the animation state is cleared (the current offset stays).
1718                self.scroll_target.remove(&id);
1719                self.scroll_velocity.remove(&id);
1720            }
1721        }
1722        animating | self.advance_scrollbars(regions, dt)
1723    }
1724
1725    /// Advances every area's **scrollbar fade** by `dt`, and returns `true` while any of
1726    /// them has something left to do — including simply *waiting* to start going.
1727    ///
1728    /// The bar arrives on movement and leaves `BAR_TIME_TO_FADE` after it stops. Nothing
1729    /// reports that movement: the offset is written from the drag handler, from the
1730    /// fling and from the spring, and none of the three leaves word. So this compares
1731    /// the offset against the one it saw last frame, which catches every writer there
1732    /// is, and every writer there will be.
1733    fn advance_scrollbars(&mut self, regions: &[Scrollable], dt: f32) -> bool {
1734        self.scrollbar_fade
1735            .retain(|id, _| regions.iter().any(|area| area.id == *id));
1736        let mut animating = false;
1737        for area in regions {
1738            let now = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
1739            let hovered = self.scrollbar_hovered == Some(area.id);
1740            let held = hovered || self.scrollbar_dragged == Some(area.id);
1741            let fade = self.scrollbar_fade.entry(area.id).or_insert(ScrollbarFade {
1742                seen: now,
1743                // An area **nobody has moved yet** has no bar: the reference's fade
1744                // starts closed and is only ever opened by a scroll notification. A
1745                // list that has just appeared shows its content, not its furniture.
1746                since: BAR_TIME_TO_FADE,
1747                ..ScrollbarFade::default()
1748            });
1749            if fade.seen != now {
1750                fade.seen = now;
1751                fade.since = 0.0;
1752            } else if held {
1753                fade.since = 0.0;
1754            } else {
1755                fade.since += dt;
1756            }
1757            let waiting = fade.since < BAR_TIME_TO_FADE;
1758            let target = if waiting { 1.0 } else { 0.0 };
1759            approach(&mut fade.opacity, target, dt / BAR_FADE, &mut animating);
1760            let warm = if hovered { 1.0 } else { 0.0 };
1761            approach(&mut fade.hover, warm, dt / BAR_HOVER, &mut animating);
1762            // The wait is not a movement, but it still needs a frame at the end of it,
1763            // or the bar would sit there until something else happened to ask for one.
1764            if waiting && !held {
1765                animating = true;
1766            }
1767        }
1768        animating
1769    }
1770
1771    /// What `id`'s scrollbar looks like this frame. An area nothing has touched, or one
1772    /// rendered in isolation with no runtime behind it, answers a closed fade.
1773    pub fn scrollbar_fade_of(&self, id: WidgetId) -> ScrollbarFade {
1774        self.scrollbar_fade.get(&id).copied().unwrap_or_default()
1775    }
1776
1777    /// Samples one region's fling for this frame. Returns `true` while it runs.
1778    ///
1779    /// An axis ends either because its simulation says so, or because it hit an edge
1780    /// the physics does not let it cross — under clamping physics the simulation
1781    /// knows nothing of the bounds, so reaching one **stops that axis dead**, which
1782    /// is exactly the behaviour a platform without a bounce wants.
1783    fn advance_ballistic(&mut self, id: WidgetId, regions: &[Scrollable], dt: f32) -> bool {
1784        let Some(mut fling) = self.scroll_ballistic.get(&id).copied() else {
1785            return false;
1786        };
1787        // A region that vanished from the frame (a route changed under the fling)
1788        // has nothing left to move.
1789        let Some(area) = regions.iter().find(|area| area.id == id).copied() else {
1790            self.scroll_ballistic.remove(&id);
1791            return false;
1792        };
1793        let physics = area.physics;
1794        fling.elapsed += dt;
1795        let t = fling.elapsed;
1796        let current = self.scroll.get(&id).copied().unwrap_or((0.0, 0.0));
1797
1798        // An axis that slams into an edge reports the speed it lost there, so the
1799        // glow can show what the clamp swallowed.
1800        let mut absorbed: Vec<(GlowEdge, f32)> = Vec::new();
1801        let mut axis = |sim: &mut Option<Ballistic>, previous: f32, max: f32, vertical: bool| {
1802            let Some(active) = sim.as_ref() else {
1803                return previous;
1804            };
1805            let mut position = active.x(t);
1806            let mut finished = active.is_done(t);
1807            if !physics.allows_overscroll() {
1808                let pinned = position.clamp(0.0, max);
1809                if pinned != position {
1810                    // Hitting the edge is the end of the motion, not a pause at it.
1811                    finished = true;
1812                    absorbed.push((edge_for(vertical, position - pinned), active.dx(t)));
1813                }
1814                position = pinned;
1815            }
1816            if finished {
1817                *sim = None;
1818            }
1819            position
1820        };
1821
1822        let x = axis(&mut fling.x, current.0, area.max_x, false);
1823        let y = axis(&mut fling.y, current.1, area.max_y, true);
1824        for (edge, velocity) in absorbed {
1825            self.glow_absorb(id, area.overscroll, edge, velocity);
1826        }
1827        self.scroll.insert(id, (x, y));
1828        // The glide-to-target path must not fight the fling, nor yank the offset
1829        // back when the fling ends.
1830        self.scroll_target.insert(id, (x, y));
1831        self.scroll_velocity.remove(&id);
1832
1833        if fling.is_empty() {
1834            self.scroll_ballistic.remove(&id);
1835            self.scroll_target.remove(&id);
1836            // Settled on an end the content no longer reaches — it shrank under the fling,
1837            // whose simulation was built for the old one. Spring on to the end there is now:
1838            // left to the correction at rest, the offset would jump there in one frame. The
1839            // reference starts its ballistic again on new dimensions for the same reason.
1840            let inside = inside_extent((x, y), (area.max_x, area.max_y));
1841            let slack = physics.tolerance().distance;
1842            if (x - inside.0).abs() > slack || (y - inside.1).abs() > slack {
1843                return self.fling_scroll(area, physics, (0.0, 0.0));
1844            }
1845            false
1846        } else {
1847            self.scroll_ballistic.insert(id, fling);
1848            true
1849        }
1850    }
1851
1852    /// Launches a fling on `area` from the release `velocity` (px/s, in scroll
1853    /// space), under `physics`. A release too slow to fling leaves any overscroll to
1854    /// be sprung back, and otherwise does nothing.
1855    pub fn fling_scroll(
1856        &mut self,
1857        area: Scrollable,
1858        physics: ScrollPhysics,
1859        velocity: (f32, f32),
1860    ) -> bool {
1861        let current = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
1862        // A paged area does not fling: it goes to **a** page, and the release speed
1863        // only says which one. The raw velocity is passed on rather than the
1864        // fling-filtered one, because the question a page view asks of a release is
1865        // "which way did it go", to which 60 px/s is as clear an answer as 2000.
1866        //
1867        // Unless it has been told not to snap, in which case it is a scrollable that
1868        // happens to know where its pages are: it still reports them and still glides
1869        // to the one it is asked for, and only the release falls through to the
1870        // ordinary fling below.
1871        if let Some(snap) = area.page.filter(|snap| snap.snapping) {
1872            let (metrics, velocity) = if snap.horizontal {
1873                (area.metrics_x(current.0), velocity.0)
1874            } else {
1875                (area.metrics_y(current.1), velocity.1)
1876            };
1877            let motion = physics.page_ballistic(metrics, velocity, snap.extent);
1878            if motion.is_none() {
1879                return false;
1880            }
1881            let ballistic = if snap.horizontal {
1882                ScrollBallistic::new(motion, None)
1883            } else {
1884                ScrollBallistic::new(None, motion)
1885            };
1886            self.scroll_ballistic.insert(area.id, ballistic);
1887            self.scroll_velocity.remove(&area.id);
1888            return true;
1889        }
1890        let min_velocity = physics.min_fling_velocity();
1891        // Below the threshold the gesture was not a fling — but an offset left out
1892        // of range still has to come home, so the physics is asked either way, with
1893        // a velocity of zero.
1894        let vx = if velocity.0.abs() >= min_velocity {
1895            velocity.0
1896        } else {
1897            0.0
1898        };
1899        let vy = if velocity.1.abs() >= min_velocity {
1900            velocity.1
1901        } else {
1902            0.0
1903        };
1904        let x = physics.ballistic(area.metrics_x(current.0), vx);
1905        let y = physics.ballistic(area.metrics_y(current.1), vy);
1906        if x.is_none() && y.is_none() {
1907            return false;
1908        }
1909        self.scroll_ballistic
1910            .insert(area.id, ScrollBallistic::new(x, y));
1911        self.scroll_velocity.remove(&area.id);
1912        true
1913    }
1914
1915    /// Acts on the page each paged view is **asking** for.
1916    ///
1917    /// A request is honoured when it *changes*, never re-asserted: the widget is
1918    /// rebuilt every frame and carries the same number each time, so a view that
1919    /// obeyed it on every frame could not be swiped at all — the finger would move
1920    /// the offset and the next frame would put it straight back.
1921    ///
1922    /// The **first** sighting of a region is its initial page, and arrives without
1923    /// an animation: an application opening on page 3 wants to be on page 3, not to
1924    /// watch it fly there.
1925    pub fn sync_pages(&mut self, regions: &[Scrollable]) {
1926        for area in regions {
1927            let Some(snap) = area.page else { continue };
1928            let previous = self.page_requested.insert(area.id, snap.requested);
1929            if previous == Some(snap.requested) {
1930                continue;
1931            }
1932            let max = if snap.horizontal {
1933                area.max_x
1934            } else {
1935                area.max_y
1936            };
1937            let offset = snap
1938                .offset_of(snap.requested.min(snap.count.saturating_sub(1)))
1939                .clamp(0.0, max);
1940            let current = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
1941            let target = if snap.horizontal {
1942                (offset, current.1)
1943            } else {
1944                (current.0, offset)
1945            };
1946            match previous {
1947                // Never seen: this is where the view opens.
1948                None => {
1949                    self.scroll.insert(area.id, target);
1950                    self.scroll_target.remove(&area.id);
1951                    self.scroll_velocity.remove(&area.id);
1952                }
1953                // A change under way: glide, and let go of anything already moving
1954                // the offset — the application has just overruled it.
1955                Some(_) => {
1956                    self.scroll_ballistic.remove(&area.id);
1957                    self.scroll_target.insert(area.id, target);
1958                }
1959            }
1960        }
1961    }
1962
1963    /// Brings each region's **kept** box into view when it has changed.
1964    ///
1965    /// A tab bar wider than its window is what this is for: the application selects the
1966    /// eighth tab and the bar is still showing the first three, with nothing to say the
1967    /// selection went anywhere.
1968    ///
1969    /// Acted on **when the key changes**, never re-asserted. The box moves as the region
1970    /// scrolls, so a region that chased the box itself would pin the strip in place and
1971    /// no finger could move it — the same trap [`Runtime::sync_pages`] avoids, and the
1972    /// same answer.
1973    ///
1974    /// The **first** sighting arrives without an animation — a bar restored on its
1975    /// eighth tab wants to be on the eighth tab, not to watch it slide there — and the
1976    /// walk has already drawn that first frame at the same offset, so the jump is
1977    /// invisible. It is retained here all the same: without it the region would have no
1978    /// offset of its own, and the frame after would find nothing to keep it there.
1979    pub fn sync_visible(&mut self, regions: &[Scrollable]) {
1980        for area in regions {
1981            let Some(keep) = area.keep_visible else {
1982                continue;
1983            };
1984            let previous = self.kept_visible.insert(area.id, keep.key);
1985            if previous == Some(keep.key) {
1986                continue;
1987            }
1988            let (offset, _) = area.keep_offset(keep);
1989            match previous {
1990                // Never seen: this is where the region opens.
1991                None => {
1992                    self.scroll.insert(area.id, offset);
1993                    self.scroll_target.remove(&area.id);
1994                    self.scroll_velocity.remove(&area.id);
1995                }
1996                // A change: glide, and let go of anything already moving the offset,
1997                // since the selection has just overruled it.
1998                Some(_) => {
1999                    self.scroll_ballistic.remove(&area.id);
2000                    self.scroll_target.insert(area.id, offset);
2001                }
2002            }
2003        }
2004    }
2005
2006    /// The paged views whose page **on screen** has just changed, and the page each
2007    /// now shows.
2008    ///
2009    /// Reported as soon as the rounding tips — mid-drag, not once the spring has
2010    /// settled — because that is when a reader would say they are on the next page.
2011    /// A caller turns each into a message; the runtime holds no messages of its own.
2012    ///
2013    /// A view **appearing** is not a page change: the first sighting of a region is
2014    /// recorded silently, whatever page it opens on. An application that opens on
2015    /// page 3 already knows it is on page 3, and being told so on the first frame
2016    /// would only invite it to answer.
2017    pub fn page_changes(&mut self, regions: &[Scrollable]) -> Vec<(WidgetId, usize)> {
2018        let mut changed = Vec::new();
2019        for area in regions {
2020            let Some(snap) = area.page else { continue };
2021            let offset = self.scroll.get(&area.id).copied().unwrap_or_else(|| {
2022                let start = snap.offset_of(snap.requested);
2023                if snap.horizontal {
2024                    (start, 0.0)
2025                } else {
2026                    (0.0, start)
2027                }
2028            });
2029            let page = snap.page_at(if snap.horizontal { offset.0 } else { offset.1 });
2030            match self.page_shown.insert(area.id, page) {
2031                Some(previous) if previous != page => changed.push((area.id, page)),
2032                _ => {}
2033            }
2034        }
2035        changed
2036    }
2037
2038    /// Is region `id` **still**? Nothing is driving it: no finger, no fling, and no
2039    /// spring left to run.
2040    ///
2041    /// The three writers of an offset, asked in one place. A caller that asked only two
2042    /// of them would call a region at rest in the middle of a fling.
2043    pub fn scroll_at_rest(&self, id: WidgetId) -> bool {
2044        self.scroll_held != Some(id)
2045            && !self.scroll_ballistic.contains_key(&id)
2046            && !self.scroll_target.contains_key(&id)
2047    }
2048
2049    /// Where region `id` is **drawn**, its content now reaching no further than `max`: the
2050    /// retained offset, brought inside `[0, max]` when nothing owns it. `None` when it has
2051    /// never been scrolled.
2052    ///
2053    /// The walk reads this rather than the map because the content can shrink in the very
2054    /// frame being built — a row of labels at a smaller text size — and that frame should
2055    /// already show the content where it rests, as the reference corrects its position
2056    /// during layout. [`Runtime::keep_scroll_in_range`] retains the same answer afterwards.
2057    /// A finger, a fling or a glide keeps what it has: each of them owns the offset, and
2058    /// each already brings it home when it lets go.
2059    pub fn scroll_offset_within(&self, id: WidgetId, max: (f32, f32)) -> Option<(f32, f32)> {
2060        let offset = self.scroll.get(&id).copied()?;
2061        Some(if self.scroll_at_rest(id) {
2062            inside_extent(offset, max)
2063        } else {
2064            offset
2065        })
2066    }
2067
2068    /// Brings every region **nobody owns** back inside its content, and answers whether any
2069    /// had strayed.
2070    ///
2071    /// An offset at rest can only be out of range because the content or the viewport
2072    /// changed under it: a drag, a fling and a glide all settle inside. Found on a phone in
2073    /// milestone 533 — a row of filters swiped to its end at a large text size stayed
2074    /// swiped once the text was small again and the row fitted, its first label cut at the
2075    /// left and empty room at the right.
2076    ///
2077    /// Corrected at once rather than sprung, as the reference's default physics does for a
2078    /// position that was in range and is not animating. The scrollbar is not summoned for
2079    /// it: nothing scrolled, the content changed.
2080    pub fn keep_scroll_in_range(&mut self, regions: &[Scrollable]) -> bool {
2081        let mut corrected = false;
2082        for area in regions {
2083            let Some(offset) = self.scroll.get(&area.id).copied() else {
2084                continue;
2085            };
2086            let inside = inside_extent(offset, (area.max_x, area.max_y));
2087            if inside == offset || !self.scroll_at_rest(area.id) {
2088                continue;
2089            }
2090            self.scroll.insert(area.id, inside);
2091            if let Some(fade) = self
2092                .scrollbar_fade
2093                .get_mut(&area.id)
2094                .filter(|fade| fade.seen == offset)
2095            {
2096                fade.seen = inside;
2097            }
2098            corrected = true;
2099        }
2100        corrected
2101    }
2102
2103    /// The scroll regions whose offset is worth reporting this frame, and where each is
2104    /// — the regions' half of [`crate::Widget::on_scroll`]. `grain` answers
2105    /// [`crate::Widget::scroll_grain`] for a region; a caller turns each result into a
2106    /// message, the runtime holding none of its own.
2107    ///
2108    /// Three rules, and each of them is a decision:
2109    ///
2110    /// - **A region appearing is not a movement** — as long as it appears where an
2111    ///   application would assume it is. A region seen for the first time at rest at
2112    ///   `(0, 0)` is recorded silently: telling an application on the first frame of
2113    ///   every screen that its list is at the top would only invite it to answer, and it
2114    ///   already knew. A region that appears **somewhere else** — restored, or opened at
2115    ///   a box it was asked to keep in view — is reported, because that one is news.
2116    /// - **A grain suppresses the reports in between, never the last one.** While the
2117    ///   region is moving, nothing under `grain` pixels from the last report is sent; the
2118    ///   moment it comes to rest, a position different from the last reported one is sent
2119    ///   whatever the distance. A coarse grain therefore costs frames, never accuracy.
2120    /// - **The baseline is what was reported**, not the previous frame. Measuring
2121    ///   against the previous frame would let a slow drag creep the whole way down a
2122    ///   list, a fraction of a grain at a time, without ever saying so.
2123    pub fn scroll_changes(
2124        &mut self,
2125        regions: &[Scrollable],
2126        grain: impl Fn(WidgetId) -> f32,
2127    ) -> Vec<(WidgetId, crate::scrollposition::ScrollPosition)> {
2128        let mut changed = Vec::new();
2129        for area in regions {
2130            let offset = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
2131            let previous = self.scroll_reported.get(&area.id).copied();
2132            let report = match previous {
2133                None => offset != (0.0, 0.0),
2134                Some(previous) if previous == offset => false,
2135                Some(previous) => {
2136                    let grain = grain(area.id).max(0.0);
2137                    let moved = (offset.0 - previous.0)
2138                        .abs()
2139                        .max((offset.1 - previous.1).abs());
2140                    moved >= grain || self.scroll_at_rest(area.id)
2141                }
2142            };
2143            if report {
2144                changed.push((
2145                    area.id,
2146                    crate::scrollposition::ScrollPosition::of(area, offset),
2147                ));
2148            }
2149            if report || previous.is_none() {
2150                self.scroll_reported.insert(area.id, offset);
2151            }
2152        }
2153        self.scroll_reported
2154            .retain(|id, _| regions.iter().any(|area| area.id == *id));
2155        changed
2156    }
2157
2158    /// Moves region `area` where `request` asks. `true` when it was acted on.
2159    ///
2160    /// **A finger refuses it.** An offset has one owner at a time, and while a hand is
2161    /// on the content that owner is the hand — a list that jumped out from under a
2162    /// finger because a timer fired would be the framework overruling the reader.
2163    pub fn scroll_to(
2164        &mut self,
2165        area: &Scrollable,
2166        request: crate::scrollposition::ScrollTo,
2167    ) -> bool {
2168        if request.is_empty() || self.scroll_held == Some(area.id) {
2169            return false;
2170        }
2171        let current = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
2172        let to = request.resolve(area, current);
2173        // A fling in flight has just been overruled, animated or not: the request is
2174        // the more recent statement of where this list should be.
2175        self.scroll_ballistic.remove(&area.id);
2176        if request.animate {
2177            self.scroll_target.insert(area.id, to);
2178        } else {
2179            self.scroll.insert(area.id, to);
2180            self.scroll_target.remove(&area.id);
2181            self.scroll_velocity.remove(&area.id);
2182        }
2183        true
2184    }
2185
2186    /// A finger takes hold of `id`: from now until [`Runtime::release_scroll`],
2187    /// this region's offset moves only when the finger says so. Any fling in
2188    /// flight is caught, since the finger has just overruled it.
2189    /// Records a change to field `id`, which **was** `before`, `since` seconds after the
2190    /// last one — see [`crate::UndoHistory::record`] for what counts as one step.
2191    pub fn record_edit(
2192        &mut self,
2193        id: WidgetId,
2194        before: crate::undo::EditSnapshot,
2195        kind: crate::undo::EditKind,
2196        since: f32,
2197    ) {
2198        self.undo.entry(id).or_default().record(before, kind, since);
2199    }
2200
2201    /// Ends the run in progress in field `id`: the caret moved, so what is typed next is a
2202    /// step of its own.
2203    pub fn close_edit_run(&mut self, id: WidgetId) {
2204        if let Some(history) = self.undo.get_mut(&id) {
2205            history.close_run();
2206        }
2207    }
2208
2209    /// Steps field `id` back one change, `current` being where it is now. `None` when
2210    /// there is nothing to undo.
2211    pub fn undo_edit(
2212        &mut self,
2213        id: WidgetId,
2214        current: crate::undo::EditSnapshot,
2215    ) -> Option<crate::undo::EditSnapshot> {
2216        self.undo.get_mut(&id)?.undo(current)
2217    }
2218
2219    /// Steps field `id` forward again. `None` when nothing was undone.
2220    pub fn redo_edit(
2221        &mut self,
2222        id: WidgetId,
2223        current: crate::undo::EditSnapshot,
2224    ) -> Option<crate::undo::EditSnapshot> {
2225        self.undo.get_mut(&id)?.redo(current)
2226    }
2227
2228    /// A finger takes hold of the region `id`. A scroll offset has one owner at a time, so
2229    /// nothing else moves it until [`release_scroll`](Self::release_scroll); see
2230    /// [`scroll_held`](Self::scroll_held).
2231    pub fn hold_scroll(&mut self, id: WidgetId) {
2232        self.scroll_held = Some(id);
2233    }
2234
2235    /// The finger lets go: the offset is up for grabs again — a fling, a spring
2236    /// back from an overscroll, or nothing.
2237    pub fn release_scroll(&mut self, id: WidgetId) {
2238        if self.scroll_held == Some(id) {
2239            self.scroll_held = None;
2240        }
2241    }
2242
2243    /// Feeds `overscroll` px of refused movement into the pull of the refresh area
2244    /// `id`, over a scrollable of `extent` px.
2245    ///
2246    /// This is the same measurement the glow is fed — what
2247    /// `apply_boundary_conditions` refused — routed to a different consumer. Where a
2248    /// refresh area is listening, the glow on that edge stands down: two answers to
2249    /// one gesture would say the same thing twice.
2250    pub fn refresh_pull(&mut self, id: WidgetId, overscroll: f32, extent: f32) {
2251        crate::refresh::pull_into(&mut self.refresh, id, overscroll, extent);
2252    }
2253
2254    /// Moves the dismissible `id` by `delta` px along its axis, over an item of
2255    /// `extent` px.
2256    pub fn dismiss_drag(
2257        &mut self,
2258        id: WidgetId,
2259        delta: f32,
2260        extent: f32,
2261        axis: crate::dismiss::DismissAxis,
2262    ) {
2263        crate::dismiss::drag_into(&mut self.dismiss, id, delta, extent, axis);
2264    }
2265
2266    /// The finger lets go of the dismissible `id`. Returns the direction it is being
2267    /// dismissed in, or `None` when it slides back.
2268    pub fn dismiss_release(
2269        &mut self,
2270        id: WidgetId,
2271        velocity: f32,
2272        cross: f32,
2273        axis: crate::dismiss::DismissAxis,
2274        threshold: f32,
2275    ) -> Option<crate::dismiss::DismissDirection> {
2276        crate::dismiss::release_of(&mut self.dismiss, id, velocity, cross, axis, threshold)
2277    }
2278
2279    /// How much of a dismissed item's box is left, while its gap closes. `None` = not
2280    /// collapsing, so the item keeps the size its style asks for.
2281    pub fn dismiss_extent_factor(&self, id: WidgetId) -> Option<f32> {
2282        self.dismiss
2283            .get(&id)
2284            .filter(|s| s.phase() == crate::dismiss::DismissPhase::Collapse)
2285            .map(|s| s.extent_factor())
2286    }
2287
2288    /// Advances every dismissible of the frame by `dt`. Returns `(still animating, the
2289    /// items whose gap has just closed)` — the second being what the shell turns into
2290    /// messages.
2291    pub fn advance_dismiss(
2292        &mut self,
2293        items: &[crate::dismiss::Dismissable],
2294        dt: f32,
2295    ) -> (bool, Vec<(WidgetId, crate::dismiss::DismissDirection)>) {
2296        crate::dismiss::advance_all(&mut self.dismiss, items, dt)
2297    }
2298
2299    /// The height of the sheet `id`, as a share of its box: the retained one, or its
2300    /// initial one while nothing has moved it.
2301    pub fn sheet_size(&self, id: WidgetId, spec: &crate::sheet::SheetSpec) -> f32 {
2302        crate::sheet::size_of(&self.sheets, id, spec)
2303    }
2304
2305    /// Moves the sheet `id` by `delta`, a share of its box — held by the finger until
2306    /// [`Runtime::sheet_release`].
2307    pub fn sheet_drag(
2308        &mut self,
2309        id: WidgetId,
2310        spec: &crate::sheet::SheetSpec,
2311        delta: f32,
2312        available: f32,
2313    ) {
2314        crate::sheet::drag_into(&mut self.sheets, id, spec, delta, available);
2315    }
2316
2317    /// A finger lands on the sheet `id`: a settle in progress stops where it is.
2318    pub fn sheet_hold(&mut self, id: WidgetId) {
2319        crate::sheet::hold_of(&mut self.sheets, id);
2320    }
2321
2322    /// The finger lets go of the sheet `id` at `velocity` px/s, positive growing it, over
2323    /// a box `available` px tall — from `list`, when the finger was on the list inside it,
2324    /// which is then handed whatever is left of a throw that reaches full height.
2325    pub fn sheet_release(
2326        &mut self,
2327        id: WidgetId,
2328        spec: &crate::sheet::SheetSpec,
2329        available: f32,
2330        velocity: f32,
2331        list: Option<WidgetId>,
2332    ) {
2333        crate::sheet::release_of(&mut self.sheets, id, spec, available, velocity, list);
2334    }
2335
2336    /// Asks the sheet `id` for `to` — an application's request, by way of `Command::sheet`.
2337    /// `false` when a finger is on it, which refuses it, as a finger refuses a scroll
2338    /// request.
2339    pub fn sheet_to(
2340        &mut self,
2341        id: WidgetId,
2342        spec: &crate::sheet::SheetSpec,
2343        to: &crate::sheet::SheetTo,
2344    ) -> bool {
2345        crate::sheet::go_to(&mut self.sheets, id, spec, to)
2346    }
2347
2348    /// Advances every sheet of the frame by `dt`. Returns `(still moving, the sheets
2349    /// lowered to nothing on this frame)` — the second being what the shell turns into
2350    /// messages. A throw that reached full height is kept for
2351    /// [`Runtime::take_sheet_handovers`].
2352    pub fn advance_sheets(
2353        &mut self,
2354        areas: &[crate::sheet::SheetArea],
2355        dt: f32,
2356    ) -> (bool, Vec<WidgetId>) {
2357        let step = crate::sheet::advance_all(&mut self.sheets, areas, dt);
2358        self.sheet_handovers.extend(step.handovers);
2359        (step.moving, step.dismissed)
2360    }
2361
2362    /// The throws that carried a sheet to full height since the last call: the list each
2363    /// goes on in, and the velocity to fling it at, in px/s growing its offset.
2364    ///
2365    /// The sheet does not fling the list itself, because a fling is started under the
2366    /// list's physics, and those are the application's to choose.
2367    pub fn take_sheet_handovers(&mut self) -> Vec<(WidgetId, f32)> {
2368        std::mem::take(&mut self.sheet_handovers)
2369    }
2370
2371    /// Calls off the pull of `id` without asking for anything — the list scrolled away
2372    /// from its top edge, or the gesture was cancelled.
2373    pub fn refresh_cancel(&mut self, id: WidgetId) {
2374        crate::refresh::cancel_of(&mut self.refresh, id);
2375    }
2376
2377    /// Ends the pull of `id`. Returns `true` when it was armed, and so when the
2378    /// application should be asked to refresh.
2379    pub fn refresh_release(&mut self, id: WidgetId) -> bool {
2380        crate::refresh::release_of(&mut self.refresh, id)
2381    }
2382
2383    /// Advances every refresh area of the frame by `dt`, reading each one's current
2384    /// `refreshing` flag from `areas`, and drops those that have gone quiet. Returns
2385    /// `true` while any is still moving.
2386    pub fn advance_refresh(&mut self, areas: &[crate::refresh::Refreshable], dt: f32) -> bool {
2387        crate::refresh::advance_all(&mut self.refresh, areas, dt)
2388    }
2389
2390    /// Tells the glow on one edge of `id` that a finger is dragging past it.
2391    ///
2392    /// `overscroll` is the movement the physics **refused** — which is exactly the
2393    /// distance the user asked for and did not get, and so exactly what the glow is
2394    /// there to acknowledge.
2395    ///
2396    /// `indicator` is what the area's scroll behaviour shows past an edge; `None` shows
2397    /// nothing, as on iOS and the desktops (milestone 616).
2398    #[allow(clippy::too_many_arguments)]
2399    pub fn glow_pull(
2400        &mut self,
2401        id: WidgetId,
2402        indicator: Option<crate::physics::OverscrollIndicator>,
2403        edge: GlowEdge,
2404        overscroll: f32,
2405        extent: f32,
2406        cross_offset: f32,
2407        cross_extent: f32,
2408    ) {
2409        let Some(indicator) = indicator else {
2410            return;
2411        };
2412        if overscroll.abs() < 1e-3 {
2413            return;
2414        }
2415        if indicator == crate::physics::OverscrollIndicator::Stretch {
2416            self.scroll_stretch
2417                .entry(id)
2418                .or_default()
2419                .axis_mut(edge)
2420                .pull(overscroll, crate::stretch::is_start(edge), extent);
2421            return;
2422        }
2423        self.scroll_glow.entry(id).or_default().edge_mut(edge).pull(
2424            overscroll,
2425            extent,
2426            cross_offset,
2427            cross_extent,
2428        );
2429    }
2430
2431    /// Tells the glow on one edge of `id` that a fling just landed on it.
2432    pub fn glow_absorb(
2433        &mut self,
2434        id: WidgetId,
2435        indicator: Option<crate::physics::OverscrollIndicator>,
2436        edge: GlowEdge,
2437        velocity: f32,
2438    ) {
2439        let Some(indicator) = indicator else {
2440            return;
2441        };
2442        if indicator == crate::physics::OverscrollIndicator::Stretch {
2443            self.scroll_stretch
2444                .entry(id)
2445                .or_default()
2446                .axis_mut(edge)
2447                .absorb_impact(velocity, crate::stretch::is_start(edge));
2448            return;
2449        }
2450        self.scroll_glow
2451            .entry(id)
2452            .or_default()
2453            .edge_mut(edge)
2454            .absorb_impact(velocity);
2455    }
2456
2457    /// Tells every glow of `id` that the gesture is over, so a held pull can fade.
2458    pub fn glow_scroll_end(&mut self, id: WidgetId) {
2459        if let Some(glows) = self.scroll_glow.get_mut(&id) {
2460            glows.scroll_end();
2461        }
2462        self.stretch_release(id);
2463    }
2464
2465    /// Lets the stretch of `id` go: the content moved again, and the reference releases a
2466    /// stretch as soon as it scrolls (milestone 591).
2467    pub fn stretch_release(&mut self, id: WidgetId) {
2468        if let Some(stretch) = self.scroll_stretch.get_mut(&id) {
2469            stretch.release();
2470        }
2471    }
2472
2473    /// Advances every glow by `dt`, dropping those that have gone quiet. Returns
2474    /// `true` while any is still animating.
2475    pub fn advance_glow(&mut self, dt: f32) -> bool {
2476        let mut animating = false;
2477        self.scroll_stretch.retain(|_, stretch| {
2478            animating |= stretch.advance(dt);
2479            !stretch.is_idle()
2480        });
2481        self.scroll_glow.retain(|_, glows| {
2482            animating |= glows.advance(dt);
2483            !glows.is_idle()
2484        });
2485        animating
2486    }
2487
2488    /// A finger landed on the inked surface `id`, at `origin` in the surface's own
2489    /// coordinates, on a box of `size`. Starts a splash.
2490    pub fn ink_press(&mut self, id: WidgetId, origin: Point, size: Size) {
2491        self.ink.entry(id).or_default().press(origin, size);
2492    }
2493
2494    /// The tap on `id` completed: its splash finishes growing and begins to fade.
2495    pub fn ink_confirm(&mut self, id: WidgetId) {
2496        if let Some(ripples) = self.ink.get_mut(&id) {
2497            ripples.confirm();
2498        }
2499    }
2500
2501    /// Advances every splash by `dt`, dropping those that have gone. Returns `true`
2502    /// while any is still moving.
2503    ///
2504    /// A splash still waiting for a finger that is no longer down is cancelled here
2505    /// rather than left hanging. A release that lands on the widget confirms it first
2506    /// (the shell calls [`Runtime::ink_confirm`] before the next frame); anything else
2507    /// — the finger slid off, the widget was unmounted mid-press, a gesture was taken
2508    /// over by a scroll — leaves nothing to confirm it, and without this sweep the ink
2509    /// would sit there for the life of the application.
2510    pub fn advance_ink(&mut self, dt: f32) -> bool {
2511        if self.ink.is_empty() {
2512            return false;
2513        }
2514        let pressed = self.input.pressed;
2515        let mut animating = false;
2516        self.ink.retain(|id, ripples| {
2517            if Some(*id) != pressed {
2518                ripples.cancel();
2519            }
2520            animating |= ripples.advance(dt);
2521            !ripples.is_idle()
2522        });
2523        animating
2524    }
2525
2526    /// Cancels any fling on `id` — a new gesture, a wheel notch or a programmatic
2527    /// scroll takes precedence over momentum from the last one.
2528    pub fn stop_scroll_fling(&mut self, id: WidgetId) -> Option<ScrollBallistic> {
2529        self.scroll_ballistic.remove(&id)
2530    }
2531    /// Catches a fling under a new press: stops it, and returns the momentum the
2532    /// next release inherits from it, per axis, in px/s.
2533    ///
2534    /// It is what makes repeated swipes accelerate on the platforms that do that;
2535    /// where they do not, [`ScrollPhysics::carried_momentum`] returns zero and this
2536    /// is simply a stop.
2537    pub fn catch_scroll_fling(&mut self, id: WidgetId, physics: ScrollPhysics) -> (f32, f32) {
2538        let Some(fling) = self.scroll_ballistic.remove(&id) else {
2539            return (0.0, 0.0);
2540        };
2541        let carried = |sim: Option<Ballistic>| {
2542            sim.map(|s| physics.carried_momentum(s.dx(fling.elapsed)))
2543                .unwrap_or(0.0)
2544        };
2545        (carried(fling.x), carried(fling.y))
2546    }
2547
2548    /// Advances the pan *fling* of the interactive viewports: each velocity moves the
2549    /// translation (decelerated by exponential friction) and is then **clamped** to its
2550    /// viewport — hitting an edge cancels the velocity on that axis. `viewports` supplies
2551    /// the viewport of each interactive viewer (from the current frame). Returns `true`
2552    /// if a fling is still running.
2553    pub fn advance_interactive(&mut self, viewports: &[(WidgetId, Rect)], dt: f32) -> bool {
2554        use crate::interactive::{PAN_FRICTION, PAN_MIN_VELOCITY};
2555        if self.interactive_velocity.is_empty() {
2556            return false;
2557        }
2558        let ids: Vec<WidgetId> = self.interactive_velocity.keys().copied().collect();
2559        let decay = (-PAN_FRICTION * dt).exp();
2560        let mut animating = false;
2561        for id in ids {
2562            let mut v = self
2563                .interactive_velocity
2564                .get(&id)
2565                .copied()
2566                .unwrap_or((0.0, 0.0));
2567            let mut view = self.interactive.get(&id).copied().unwrap_or_default();
2568            let moved = view.pan(v.0 * dt, v.1 * dt);
2569            // Clamping: an axis that hits a bound cancels its velocity (no bounce).
2570            if let Some((_, vp)) = viewports.iter().find(|(i, _)| *i == id) {
2571                let clamped = moved.clamped(*vp);
2572                if (clamped.tx - moved.tx).abs() > 1e-3 {
2573                    v.0 = 0.0;
2574                }
2575                if (clamped.ty - moved.ty).abs() > 1e-3 {
2576                    v.1 = 0.0;
2577                }
2578                view = clamped;
2579            } else {
2580                view = moved;
2581            }
2582            self.interactive.insert(id, view);
2583            v.0 *= decay;
2584            v.1 *= decay;
2585            if v.0.hypot(v.1) < PAN_MIN_VELOCITY {
2586                self.interactive_velocity.remove(&id);
2587            } else {
2588                self.interactive_velocity.insert(id, v);
2589                animating = true;
2590            }
2591        }
2592        animating
2593    }
2594
2595    /// Fades out the opacity of outgoing subtrees; forgets those that have reached
2596    /// 0. Returns `true` if an exit is still running.
2597    pub fn advance_leaving(&mut self, dt: f32) -> bool {
2598        let step = if ANIM_DURATION > 0.0 {
2599            dt / ANIM_DURATION
2600        } else {
2601            1.0
2602        };
2603        let mut animating = false;
2604        self.leaving.retain(|_, (_, opacity)| {
2605            *opacity -= step;
2606            if *opacity > 0.0 {
2607                animating = true;
2608                true
2609            } else {
2610                false
2611            }
2612        });
2613        animating
2614    }
2615}
2616
2617#[cfg(test)]
2618mod tests {
2619    use super::*;
2620
2621    /// A vertical scroll region `max` px long, in a 400 px viewport, with no
2622    /// physics of its own.
2623    fn region(id: WidgetId, max: f32) -> Scrollable {
2624        Scrollable {
2625            id,
2626            viewport: Rect::new(0.0, 0.0, 300.0, 400.0),
2627            max_x: 0.0,
2628            max_y: max,
2629            axis: crate::scroll::Axis::Vertical,
2630            physics: crate::ScrollPhysics::CLAMPING,
2631            overscroll: Some(crate::OverscrollIndicator::Glow),
2632            fling: frus_core::VelocityStrategy::Regression,
2633            refresh: None,
2634            page: None,
2635            reverse_x: false,
2636            reverse_y: false,
2637            host: None,
2638            keep_visible: None,
2639        }
2640    }
2641
2642    /// The same region, with these physics settled on it, as its scroll behaviour would.
2643    fn region_with(physics: ScrollPhysics, id: WidgetId, max: f32) -> Scrollable {
2644        Scrollable {
2645            physics,
2646            ..region(id, max)
2647        }
2648    }
2649
2650    /// A three-page horizontal view, 300 px wide, asking for `requested`.
2651    fn paged_region(id: WidgetId, requested: usize) -> Scrollable {
2652        Scrollable {
2653            id,
2654            viewport: Rect::new(0.0, 0.0, 300.0, 400.0),
2655            max_x: 600.0,
2656            max_y: 0.0,
2657            axis: crate::scroll::Axis::Vertical,
2658            physics: crate::ScrollPhysics::CLAMPING,
2659            overscroll: Some(crate::OverscrollIndicator::Glow),
2660            fling: frus_core::VelocityStrategy::Regression,
2661            refresh: None,
2662            page: Some(crate::PageSnap {
2663                snapping: true,
2664                extent: 300.0,
2665                count: 3,
2666                requested,
2667                horizontal: true,
2668            }),
2669            reverse_x: false,
2670            reverse_y: false,
2671            host: None,
2672            keep_visible: None,
2673        }
2674    }
2675
2676    #[test]
2677    fn a_paged_release_springs_to_a_page_and_never_coasts() {
2678        let id = WidgetId::ROOT.child(0);
2679        let mut rt = Runtime::default();
2680        let area = paged_region(id, 0);
2681        rt.scroll.insert(id, (40.0, 0.0));
2682        // A flick far too slow to fling an ordinary list still turns the page.
2683        assert!(rt.fling_scroll(area, ScrollPhysics::CLAMPING, (60.0, 0.0)));
2684        for _ in 0..200 {
2685            rt.advance_scroll(&[area], 1.0 / 60.0);
2686        }
2687        let (x, _) = rt.scroll[&id];
2688        assert!((x - 300.0).abs() < 1.0, "settled at {x}");
2689    }
2690
2691    #[test]
2692    fn a_hard_fling_on_a_paged_view_crosses_one_page() {
2693        let id = WidgetId::ROOT.child(0);
2694        let mut rt = Runtime::default();
2695        let area = paged_region(id, 0);
2696        rt.scroll.insert(id, (10.0, 0.0));
2697        rt.fling_scroll(area, ScrollPhysics::CLAMPING, (7000.0, 0.0));
2698        for _ in 0..300 {
2699            rt.advance_scroll(&[area], 1.0 / 60.0);
2700        }
2701        let (x, _) = rt.scroll[&id];
2702        assert!((x - 300.0).abs() < 1.0, "a fling must not skip pages: {x}");
2703    }
2704
2705    #[test]
2706    fn the_page_asked_for_is_taken_on_the_first_sighting_and_on_a_change() {
2707        let id = WidgetId::ROOT.child(0);
2708        let mut rt = Runtime::default();
2709        // First sighting: the view opens there, with no animation to watch.
2710        rt.sync_pages(&[paged_region(id, 2)]);
2711        assert_eq!(rt.scroll[&id], (600.0, 0.0));
2712        assert!(!rt.scroll_target.contains_key(&id));
2713
2714        // Asked again for the same page while the finger has moved it: left alone,
2715        // or the view could not be swiped at all.
2716        rt.scroll.insert(id, (450.0, 0.0));
2717        rt.sync_pages(&[paged_region(id, 2)]);
2718        assert_eq!(rt.scroll[&id], (450.0, 0.0));
2719
2720        // A new page: a glide, not a jump.
2721        rt.sync_pages(&[paged_region(id, 0)]);
2722        assert_eq!(rt.scroll[&id], (450.0, 0.0));
2723        assert_eq!(rt.scroll_target[&id], (0.0, 0.0));
2724    }
2725
2726    #[test]
2727    fn a_page_change_is_reported_once_and_never_on_arrival() {
2728        let id = WidgetId::ROOT.child(0);
2729        let mut rt = Runtime::default();
2730        let area = paged_region(id, 1);
2731        // The view appearing is not a page change, whatever page it opens on.
2732        assert!(rt.page_changes(&[area]).is_empty());
2733
2734        // Half a page across: the rounding has not tipped yet.
2735        rt.scroll.insert(id, (440.0, 0.0));
2736        assert!(rt.page_changes(&[area]).is_empty());
2737        // Past the middle: reported, and only once.
2738        rt.scroll.insert(id, (460.0, 0.0));
2739        assert_eq!(rt.page_changes(&[area]), vec![(id, 2)]);
2740        rt.scroll.insert(id, (600.0, 0.0));
2741        assert!(rt.page_changes(&[area]).is_empty());
2742    }
2743
2744    #[test]
2745    fn hover_rises_then_falls_and_clears() {
2746        let id = WidgetId::ROOT.child(0);
2747        let mut rt = Runtime::default();
2748        rt.input.hovered = Some(id);
2749
2750        // Hovered: small steps → the progress rises without reaching 1.
2751        assert!(rt.advance(0.03)); // ~0.25, still running
2752        assert!(rt.advance(0.03)); // ~0.5, still running
2753        let p = rt.hover_progress(id);
2754        assert!(p > 0.4 && p < 0.6, "progress = {p}");
2755
2756        // A large step: reaches 1.0 and stays there (no more animation).
2757        rt.advance(1.0);
2758        assert_eq!(rt.hover_progress(id), 1.0);
2759        assert!(!rt.advance(0.03));
2760
2761        // End of hover: falls back (still running), then reaches 0 and the entry disappears.
2762        rt.input.hovered = None;
2763        assert!(rt.advance(0.03));
2764        rt.advance(1.0);
2765        assert_eq!(rt.hover_progress(id), 0.0);
2766        assert!(rt.anims.is_empty());
2767    }
2768
2769    /// **A press rises and falls** (milestone 441).
2770    ///
2771    /// It was a flag and nothing else, so a state layer reached full the instant a finger
2772    /// landed and vanished the instant it left. The reference fades its press highlight
2773    /// in and out instead (`ink_highlight.dart:62`), and this is that fade in the shape
2774    /// hover and focus already had here.
2775    #[test]
2776    fn a_press_rises_then_falls_back() {
2777        let id = WidgetId::ROOT.child(3);
2778        let mut rt = Runtime::default();
2779        rt.input.hovered = Some(id);
2780        rt.input.pressed = Some(id);
2781
2782        // Under the finger: part way there, still running.
2783        assert!(rt.advance(0.05));
2784        let p = rt.press_progress(id);
2785        assert!(p > 0.0 && p < 1.0, "on the way in: {p}");
2786        rt.advance(1.0);
2787        assert_eq!(rt.press_progress(id), 1.0);
2788
2789        // Let go: it comes back down rather than disappearing, which is the same jump
2790        // the other way round.
2791        rt.input.pressed = None;
2792        assert!(rt.advance(0.05));
2793        let p = rt.press_progress(id);
2794        assert!(p > 0.0 && p < 1.0, "on the way back: {p}");
2795        rt.advance(1.0);
2796        assert_eq!(rt.press_progress(id), 0.0);
2797    }
2798
2799    /// And it is the **slower** of the two clocks: the reference gives the press 200 ms
2800    /// against the 50 it gives hover and focus (`ink_well.dart:995`). A press is the one
2801    /// interaction the pointer commits to, and it is drawn slowly enough to be seen.
2802    #[test]
2803    fn a_press_arrives_more_slowly_than_a_hover() {
2804        let id = WidgetId::ROOT.child(4);
2805        let mut rt = Runtime::default();
2806        rt.input.hovered = Some(id);
2807        rt.input.pressed = Some(id);
2808        rt.advance(0.06);
2809        let (press, hover) = (rt.press_progress(id), rt.hover_progress(id));
2810        assert!(press < hover, "press {press} should trail hover {hover}");
2811        assert!(press > 0.0, "but it is under way");
2812    }
2813
2814    /// A press counts only while the pointer is **still on the widget** — the rule
2815    /// `InputState::status_for` applies, so the progression and the flag can never
2816    /// disagree about what is happening.
2817    #[test]
2818    fn a_press_dragged_off_the_widget_comes_back_down() {
2819        let id = WidgetId::ROOT.child(5);
2820        let elsewhere = WidgetId::ROOT.child(6);
2821        let mut rt = Runtime::default();
2822        rt.input.hovered = Some(id);
2823        rt.input.pressed = Some(id);
2824        rt.advance(1.0);
2825        assert_eq!(rt.press_progress(id), 1.0);
2826
2827        // Still held, but the finger has slid away: not a press any more. Long enough
2828        // for the hover to have finished falling and the press not to have — which is
2829        // the case the forget-predicate has to survive, since an entry with nothing left
2830        // *but* a press is exactly what a release looks like on the way out.
2831        rt.input.hovered = Some(elsewhere);
2832        assert!(rt.advance(0.13));
2833        assert_eq!(rt.hover_progress(id), 0.0, "the hover is done");
2834        let p = rt.press_progress(id);
2835        assert!(
2836            p > 0.0 && p < 1.0,
2837            "and the press is still coming down: {p}"
2838        );
2839
2840        rt.advance(1.0);
2841        assert_eq!(rt.press_progress(id), 0.0);
2842    }
2843
2844    #[test]
2845    fn focus_animates_independently() {
2846        let id = WidgetId::ROOT.child(1);
2847        let mut rt = Runtime::default();
2848        rt.input.focused = Some(id);
2849        rt.advance(1.0);
2850        assert_eq!(rt.focus_progress(id), 1.0);
2851        assert_eq!(rt.hover_progress(id), 0.0);
2852    }
2853
2854    #[test]
2855    fn opacity_rises_to_one() {
2856        let id = WidgetId::ROOT.child(2);
2857        let mut rt = Runtime::default();
2858        // Mount: starts transparent.
2859        rt.anims.insert(
2860            id,
2861            Anim {
2862                opacity: 0.0,
2863                ..Default::default()
2864            },
2865        );
2866        assert!(rt.advance(0.03));
2867        let o = rt.opacity(id);
2868        assert!(o > 0.0 && o < 1.0, "opacity = {o}");
2869        rt.advance(1.0);
2870        assert_eq!(rt.opacity(id), 1.0);
2871        // Default without an entry: opaque.
2872        assert_eq!(rt.opacity(WidgetId::ROOT), 1.0);
2873    }
2874
2875    /// **Motion off means the change lands at once, not that it does not land.**
2876    ///
2877    /// The user asked for animations to be reduced or removed, so an implicit animation
2878    /// completes in the frame it starts rather than over its duration. Skipping the
2879    /// change instead would leave the interface *wrong* rather than *still*, which is
2880    /// the failure this setting is most often given.
2881    ///
2882    /// The check is both halves: the value has arrived, **and** nothing is still asking
2883    /// for another frame.
2884    #[test]
2885    fn motion_off_lands_the_change_at_once() {
2886        // A switch mounts at its target, so the animation is what happens on the
2887        // **change** — which is exactly what this setting is about.
2888        let off: crate::Switch<()> = crate::Switch::new(false);
2889        let on: crate::Switch<()> = crate::Switch::new(true);
2890
2891        let mut moving = Runtime::default();
2892        assert!(!moving.advance_values(&off, 1.0));
2893        assert!(
2894            moving.advance_values(&on, 0.03),
2895            "a live animation owes the next frame to itself"
2896        );
2897        let part_way = moving.value(WidgetId::ROOT);
2898        assert!(part_way > 0.0 && part_way < 1.0, "part way: {part_way}");
2899
2900        let mut stopped = Runtime {
2901            still: true,
2902            ..Default::default()
2903        };
2904        assert!(!stopped.advance_values(&off, 1.0));
2905        let more = stopped.advance_values(&on, 0.03);
2906        assert!(!more, "motion off: nothing is left to animate");
2907        assert_eq!(
2908            stopped.value(WidgetId::ROOT),
2909            1.0,
2910            "and the value the widget asked for has actually arrived"
2911        );
2912    }
2913
2914    /// **A value a finger holds stays where it was put** (milestone 638), whatever its
2915    /// target; let go, it travels straight to the target in the share of the duration the
2916    /// distance left is, and is no longer *placed* once it arrives.
2917    #[test]
2918    fn a_held_value_stays_and_settles_from_where_it_was_left() {
2919        let off: crate::Switch<()> = crate::Switch::new(false);
2920        let mut rt = Runtime::default();
2921        rt.advance_values(&off, 1.0);
2922        rt.hold_value(WidgetId::ROOT, 0.25);
2923        assert!(rt.value_placed(WidgetId::ROOT));
2924        assert!(!rt.advance_values(&off, 1.0), "held: nothing moves");
2925        assert_eq!(rt.value(WidgetId::ROOT), 0.25);
2926
2927        rt.release_value(WidgetId::ROOT);
2928        // A quarter of the way to go is a quarter of the 300 ms: half of it is 37.5 ms.
2929        assert!(rt.advance_values(&off, 0.0375));
2930        let half = rt.value(WidgetId::ROOT);
2931        assert!((half - 0.125).abs() < 1e-4, "linear, half way: {half}");
2932        assert!(rt.value_placed(WidgetId::ROOT), "still settling");
2933        rt.advance_values(&off, 0.0375);
2934        assert_eq!(rt.value(WidgetId::ROOT), 0.0);
2935        rt.advance_values(&off, 0.0);
2936        assert!(
2937            !rt.value_placed(WidgetId::ROOT),
2938            "arrived: no longer placed"
2939        );
2940    }
2941
2942    #[test]
2943    fn value_snaps_on_mount_then_animates() {
2944        let mut rt = Runtime::default();
2945        // Mounting a switch that is off: adopts the target (0) with no animation.
2946        let off: crate::Switch<()> = crate::Switch::new(false);
2947        assert!(!rt.advance_values(&off, 1.0));
2948        assert_eq!(rt.value(WidgetId::ROOT), 0.0);
2949
2950        // Toggled on: the value rises towards 1 in small steps.
2951        let on: crate::Switch<()> = crate::Switch::new(true);
2952        assert!(rt.advance_values(&on, 0.03));
2953        let v = rt.value(WidgetId::ROOT);
2954        assert!(v > 0.0 && v < 1.0, "value = {v}");
2955        rt.advance_values(&on, 1.0);
2956        assert_eq!(rt.value(WidgetId::ROOT), 1.0);
2957
2958        // Widget gone: the value is forgotten.
2959        let empty: crate::Container<()> = crate::Container::new();
2960        rt.advance_values(&empty, 1.0);
2961        assert!(rt.values.is_empty());
2962    }
2963
2964    /// Minimal widget exposing a tunable animated value (target, duration, curve) —
2965    /// to test the timeline without depending on a concrete widget.
2966    struct Mock {
2967        target: f32,
2968        duration: f32,
2969        curve: Curve,
2970    }
2971
2972    impl crate::widget::Widget<()> for Mock {
2973        fn style(&self) -> frus_layout::Style {
2974            frus_layout::Style::default()
2975        }
2976        fn children(&self) -> &[Box<dyn crate::widget::Widget<()>>] {
2977            &[]
2978        }
2979        fn paint(
2980            &self,
2981            _bounds: frus_core::Rect,
2982            _status: crate::interaction::Status,
2983            _theme: &crate::theme::Theme,
2984            _scene: &mut frus_core::Scene,
2985        ) {
2986        }
2987        fn on_click(&self) -> Option<()> {
2988            None
2989        }
2990        fn anim_target(&self) -> Option<f32> {
2991            Some(self.target)
2992        }
2993        fn anim_duration(&self) -> f32 {
2994            self.duration
2995        }
2996        fn anim_curve(&self) -> Curve {
2997            self.curve.clone()
2998        }
2999    }
3000
3001    /// The **curve** shapes the trajectory: at t=0.25 an *ease-in* lags behind the
3002    /// linear progress and an *ease-out* runs ahead; all of them converge on the
3003    /// target.
3004    #[test]
3005    fn curve_shapes_the_value_timeline() {
3006        let id = WidgetId::ROOT;
3007        let dt = 0.03; // t = 0.25 over a duration of 0.12
3008        let dur = 0.12;
3009        let sample = |curve: Curve| {
3010            let mut rt = Runtime::default();
3011            rt.set_value(id, 0.0);
3012            rt.advance_values(
3013                &Mock {
3014                    target: 1.0,
3015                    duration: dur,
3016                    curve,
3017                },
3018                dt,
3019            );
3020            (rt.value(id), rt)
3021        };
3022        let (ein, mut rt_in) = sample(Curve::ease_in());
3023        let (eout, mut rt_out) = sample(Curve::ease_out());
3024        let (lin, mut rt_lin) = sample(Curve::Linear);
3025
3026        assert!((lin - 0.25).abs() < 1e-3, "linear = t: {lin}");
3027        assert!(ein < 0.25, "ease-in lags: {ein}");
3028        assert!(eout > 0.25, "ease-out leads: {eout}");
3029
3030        // A large step: all of them reach the target (the curves end at 1).
3031        for rt in [&mut rt_in, &mut rt_out, &mut rt_lin] {
3032            rt.advance_values(
3033                &Mock {
3034                    target: 1.0,
3035                    duration: dur,
3036                    curve: Curve::Linear,
3037                },
3038                1.0,
3039            );
3040        }
3041        assert_eq!(rt_in.value(id), 1.0);
3042        assert_eq!(rt_out.value(id), 1.0);
3043        assert_eq!(rt_lin.value(id), 1.0);
3044    }
3045
3046    /// The animated colour **snaps** on mount then **tweens** on a change of target,
3047    /// channel by channel; a widget that has gone is forgotten.
3048    #[test]
3049    fn animated_color_tweens_between_frames() {
3050        let id = WidgetId::ROOT;
3051        let red = Color::rgb(1.0, 0.0, 0.0);
3052        let blue = Color::rgb(0.0, 0.0, 1.0);
3053        let mut rt = Runtime::default();
3054
3055        // Mounting on red: adopts the target with no transition.
3056        let start: crate::Container<()> =
3057            crate::Container::new().animated_color(red, 0.10, Curve::Linear);
3058        assert!(!rt.advance_colors(&start, 1.0));
3059        assert_eq!(rt.anim_color(id), Some(red));
3060
3061        // Blue target: linear tween, halfway ≈ (0.5, 0, 0.5).
3062        let to_blue: crate::Container<()> =
3063            crate::Container::new().animated_color(blue, 0.10, Curve::Linear);
3064        assert!(rt.advance_colors(&to_blue, 0.05));
3065        let mid = rt.anim_color(id).unwrap();
3066        assert!(
3067            (mid.r - 0.5).abs() < 0.05 && (mid.b - 0.5).abs() < 0.05,
3068            "mi-parcours = {mid:?}"
3069        );
3070
3071        // The end: blue reached.
3072        rt.advance_colors(&to_blue, 1.0);
3073        assert_eq!(rt.anim_color(id), Some(blue));
3074
3075        // Widget gone: the colour is forgotten.
3076        let empty: crate::Container<()> = crate::Container::new();
3077        rt.advance_colors(&empty, 1.0);
3078        assert_eq!(rt.anim_color(id), None);
3079    }
3080
3081    /// The animated size **snaps** on mount then **tweens** on a change of target
3082    /// (width/height); a widget that has gone is forgotten.
3083    #[test]
3084    fn animated_size_tweens_between_frames() {
3085        let id = WidgetId::ROOT;
3086        let mut rt = Runtime::default();
3087
3088        let small: crate::Container<()> =
3089            crate::Container::new().animated_size(20.0, 20.0, 0.10, Curve::Linear);
3090        assert!(!rt.advance_sizes(&small, 1.0));
3091        assert_eq!(rt.anim_size(id), Some(Size::new(20.0, 20.0)));
3092
3093        // 40×40 target: halfway through a linear tween ≈ 30×30.
3094        let big: crate::Container<()> =
3095            crate::Container::new().animated_size(40.0, 40.0, 0.10, Curve::Linear);
3096        assert!(rt.advance_sizes(&big, 0.05));
3097        let mid = rt.anim_size(id).unwrap();
3098        assert!(
3099            (mid.width - 30.0).abs() < 0.5 && (mid.height - 30.0).abs() < 0.5,
3100            "mi-parcours = {mid:?}"
3101        );
3102
3103        rt.advance_sizes(&big, 1.0);
3104        assert_eq!(rt.anim_size(id), Some(Size::new(40.0, 40.0)));
3105
3106        let empty: crate::Container<()> = crate::Container::new();
3107        rt.advance_sizes(&empty, 1.0);
3108        assert_eq!(rt.anim_size(id), None);
3109    }
3110
3111    /// The animated corner radius **snaps** on mount then **tweens** on a change of
3112    /// target (per corner); a widget that has gone is forgotten.
3113    #[test]
3114    fn animated_radius_tweens_between_frames() {
3115        let id = WidgetId::ROOT;
3116        let mut rt = Runtime::default();
3117
3118        let sharp: crate::Container<()> =
3119            crate::Container::new().animated_radius(0.0, 0.10, Curve::Linear);
3120        assert!(!rt.advance_radii(&sharp, 1.0));
3121        assert_eq!(rt.anim_radius(id), Some(BorderRadius::from(0.0)));
3122
3123        // Target 20: halfway through a linear tween ≈ 10.
3124        let round: crate::Container<()> =
3125            crate::Container::new().animated_radius(20.0, 0.10, Curve::Linear);
3126        assert!(rt.advance_radii(&round, 0.05));
3127        let mid = rt.anim_radius(id).unwrap();
3128        assert!(
3129            (mid.top_left - 10.0).abs() < 0.5,
3130            "mi-parcours = {}",
3131            mid.top_left
3132        );
3133
3134        rt.advance_radii(&round, 1.0);
3135        assert_eq!(rt.anim_radius(id), Some(BorderRadius::from(20.0)));
3136
3137        let empty: crate::Container<()> = crate::Container::new();
3138        rt.advance_radii(&empty, 1.0);
3139        assert_eq!(rt.anim_radius(id), None);
3140    }
3141
3142    /// The animated padding **snaps** on mount then **tweens** on a change of target
3143    /// (per side); a widget that has gone is forgotten.
3144    #[test]
3145    fn animated_padding_tweens_between_frames() {
3146        let id = WidgetId::ROOT;
3147        let mut rt = Runtime::default();
3148
3149        let p0: crate::Container<()> =
3150            crate::Container::new().animated_padding(0.0, 0.10, Curve::Linear);
3151        assert!(!rt.advance_paddings(&p0, 1.0));
3152        assert_eq!(rt.anim_padding(id), Some(Insets::uniform(0.0)));
3153
3154        let p20: crate::Container<()> =
3155            crate::Container::new().animated_padding(20.0, 0.10, Curve::Linear);
3156        assert!(rt.advance_paddings(&p20, 0.05)); // t = 0.5 → 10
3157        let mid = rt.anim_padding(id).unwrap();
3158        assert!((mid.left - 10.0).abs() < 0.5, "mi-parcours = {}", mid.left);
3159
3160        rt.advance_paddings(&p20, 1.0);
3161        assert_eq!(rt.anim_padding(id), Some(Insets::uniform(20.0)));
3162
3163        let empty: crate::Container<()> = crate::Container::new();
3164        rt.advance_paddings(&empty, 1.0);
3165        assert_eq!(rt.anim_padding(id), None);
3166    }
3167
3168    /// The **duration** sets the speed: for the same `dt`, a shorter transition is
3169    /// further along.
3170    #[test]
3171    fn shorter_duration_animates_faster() {
3172        let id = WidgetId::ROOT;
3173        let advance = |duration: f32| {
3174            let mut rt = Runtime::default();
3175            rt.set_value(id, 0.0);
3176            rt.advance_values(
3177                &Mock {
3178                    target: 1.0,
3179                    duration,
3180                    curve: Curve::Linear,
3181                },
3182                0.025,
3183            );
3184            rt.value(id)
3185        };
3186        let fast = advance(0.05); // t = 0.5
3187        let slow = advance(0.20); // t = 0.125
3188        assert!(
3189            fast > slow,
3190            "shorter duration further along: {fast} vs {slow}"
3191        );
3192        assert!((fast - 0.5).abs() < 1e-3, "fast = {fast}");
3193        assert!((slow - 0.125).abs() < 1e-3, "slow = {slow}");
3194    }
3195
3196    #[test]
3197    fn spring_ease_is_monotonic_no_overshoot() {
3198        assert!((spring_ease(0.0) - 0.0).abs() < 1e-6);
3199        assert!((spring_ease(1.0) - 1.0).abs() < 1e-6);
3200        // Increasing and bounded to [0,1] (no overshoot beyond 1).
3201        let mut prev = 0.0;
3202        for i in 0..=100 {
3203            let v = spring_ease(i as f32 / 100.0);
3204            assert!(v >= prev - 1e-6, "decreases at {i}");
3205            assert!(v <= 1.0 + 1e-6, "exceeds 1 at {i}");
3206            prev = v;
3207        }
3208        // Already well advanced at halfway (a gentle arrival at the end).
3209        assert!(spring_ease(0.5) > 0.7);
3210        // Clamped outside the domain.
3211        assert_eq!(spring_ease(-1.0), 0.0);
3212        assert_eq!(spring_ease(2.0), 1.0);
3213    }
3214
3215    #[test]
3216    fn scroll_springs_to_target_and_settles() {
3217        let id = WidgetId::ROOT;
3218        let mut rt = Runtime::default();
3219        rt.scroll_target.insert(id, (0.0, 100.0));
3220        rt.scroll_velocity.insert(id, (0.0, 0.0));
3221        let regions = [region(id, 200.0)];
3222        for _ in 0..600 {
3223            if !rt.advance_scroll(&regions, 0.016) {
3224                break;
3225            }
3226        }
3227        let (_, y) = rt.scroll.get(&id).copied().unwrap();
3228        assert!((y - 100.0).abs() < 1.0, "reached the target: {y}");
3229        assert!(
3230            !rt.scroll_target.contains_key(&id),
3231            "animation state cleared at rest"
3232        );
3233    }
3234
3235    /// **A scrollbar arrives when its area moves, and leaves once it has been still**
3236    /// (`scrollbar.dart:17`, `:18`): 600 ms of stillness, then 300 ms of going.
3237    ///
3238    /// Nothing announces that movement. The offset is written by the drag handler, by
3239    /// the fling and by the spring, and none of the three leaves word — so this watches
3240    /// the value itself, which catches all three and whatever is added next.
3241    #[test]
3242    fn a_bar_arrives_on_movement_and_goes_again() {
3243        let id = WidgetId::ROOT;
3244        let mut rt = Runtime::default();
3245        let regions = [region(id, 200.0)];
3246        let run = |rt: &mut Runtime, frames: usize| {
3247            for _ in 0..frames {
3248                rt.advance_scroll(&regions, 0.016);
3249            }
3250        };
3251
3252        // An area nobody has scrolled has no bar at all.
3253        run(&mut rt, 10);
3254        assert_eq!(
3255            rt.scrollbar_fade_of(id).opacity,
3256            0.0,
3257            "untouched: no bar to see"
3258        );
3259
3260        // Moved by something — anything — and it comes, over `BAR_FADE`.
3261        rt.scroll.insert(id, (0.0, 40.0));
3262        run(&mut rt, 1);
3263        let arriving = rt.scrollbar_fade_of(id).opacity;
3264        assert!(
3265            arriving > 0.0 && arriving < 1.0,
3266            "on its way in, not snapped: {arriving}"
3267        );
3268        run(&mut rt, 20);
3269        assert!(rt.scrollbar_fade_of(id).opacity > 0.99, "there");
3270
3271        // Still for less than `BAR_TIME_TO_FADE` all told: it stays put.
3272        run(&mut rt, 10);
3273        assert!(
3274            rt.scrollbar_fade_of(id).opacity > 0.99,
3275            "half a second of stillness is not enough to send it away"
3276        );
3277
3278        // And then it goes.
3279        run(&mut rt, 80);
3280        assert_eq!(rt.scrollbar_fade_of(id).opacity, 0.0, "gone");
3281    }
3282
3283    /// **A held thumb keeps its bar**, however long the hand pauses on it
3284    /// (`scrollbar.dart:1970`), and so does a bar a pointer is resting near — which is
3285    /// what lets a faded bar be found again at all (`scrollbar.dart:2132`).
3286    #[test]
3287    fn a_bar_stays_for_the_pointer_that_wants_it() {
3288        let id = WidgetId::ROOT;
3289        let regions = [region(id, 200.0)];
3290        let run = |rt: &mut Runtime, frames: usize| {
3291            for _ in 0..frames {
3292                rt.advance_scroll(&regions, 0.016);
3293            }
3294        };
3295
3296        let mut held = Runtime::default();
3297        held.scroll.insert(id, (0.0, 40.0));
3298        held.scrollbar_dragged = Some(id);
3299        run(&mut held, 140); // well past 600 ms of stillness
3300        assert!(
3301            held.scrollbar_fade_of(id).opacity > 0.99,
3302            "a thumb in a hand does not fade out from under it"
3303        );
3304
3305        // A bar that has gone entirely comes back for a pointer that reaches for it,
3306        // and the thumb warms towards its hovered colour while it is there.
3307        let mut faded = Runtime::default();
3308        run(&mut faded, 10);
3309        assert_eq!(faded.scrollbar_fade_of(id).opacity, 0.0);
3310        faded.scrollbar_hovered = Some(id);
3311        run(&mut faded, 25);
3312        assert!(faded.scrollbar_fade_of(id).opacity > 0.99, "back");
3313        assert!(faded.scrollbar_fade_of(id).hover > 0.99, "and warmed");
3314    }
3315
3316    #[test]
3317    fn interactive_fling_decelerates_settles_and_stays_bounded() {
3318        use crate::interactive::InteractiveView;
3319        let id = WidgetId::ROOT;
3320        let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
3321        let mut rt = Runtime::default();
3322        // Content zoomed ×2, flung leftwards at high speed.
3323        rt.interactive.insert(
3324            id,
3325            InteractiveView {
3326                scale: 2.0,
3327                tx: 0.0,
3328                ty: 0.0,
3329            },
3330        );
3331        rt.interactive_velocity.insert(id, (-2000.0, 0.0));
3332        let viewports = [(id, vp)];
3333        let mut frames = 0;
3334        while rt.advance_interactive(&viewports, 0.016) {
3335            frames += 1;
3336            assert!(frames < 1000, "the fling must eventually stop");
3337        }
3338        let view = rt.interactive.get(&id).copied().unwrap();
3339        // Stays clamped (content ×2 covers the viewport → tx ∈ [-200, 0]).
3340        assert!(
3341            view.tx >= -200.0 - 1e-3 && view.tx <= 0.0 + 1e-3,
3342            "clamped: {}",
3343            view.tx
3344        );
3345        assert!(
3346            !rt.interactive_velocity.contains_key(&id),
3347            "velocity cleared at rest"
3348        );
3349    }
3350
3351    #[test]
3352    fn scroll_overshoot_rubber_bands_back_to_max() {
3353        let id = WidgetId::ROOT;
3354        let mut rt = Runtime::default();
3355        // Target beyond the bound (overshoot) → must come back to max.
3356        rt.scroll_target.insert(id, (0.0, 240.0));
3357        rt.scroll_velocity.insert(id, (0.0, 0.0));
3358        let regions = [region(id, 200.0)];
3359        for _ in 0..1000 {
3360            if !rt.advance_scroll(&regions, 0.016) {
3361                break;
3362            }
3363        }
3364        let (_, y) = rt.scroll.get(&id).copied().unwrap();
3365        assert!((y - 200.0).abs() < 1.0, "back at the max bound: {y}");
3366    }
3367
3368    /// Runs a fling to completion and answers where the content came to rest.
3369    fn settle(physics: ScrollPhysics, max: f32, from: f32, velocity: f32) -> f32 {
3370        let id = WidgetId::ROOT;
3371        let mut rt = Runtime::default();
3372        let area = region_with(physics, id, max);
3373        rt.scroll.insert(id, (0.0, from));
3374        rt.fling_scroll(area, physics, (0.0, velocity));
3375        for _ in 0..1200 {
3376            if !rt.advance_scroll(&[area], 1.0 / 60.0) {
3377                break;
3378            }
3379        }
3380        assert!(
3381            rt.scroll_ballistic.is_empty(),
3382            "the fling should have finished"
3383        );
3384        rt.scroll.get(&id).copied().unwrap().1
3385    }
3386
3387    #[test]
3388    fn a_clamping_fling_stops_at_the_edge() {
3389        // Far more momentum than there is content: it must stop exactly at the end,
3390        // with no overshoot to spring back from.
3391        let rest = settle(ScrollPhysics::CLAMPING, 400.0, 0.0, 6000.0);
3392        assert_eq!(rest, 400.0, "stopped dead at the end, at {rest}");
3393        // And symmetrically at the start.
3394        let rest = settle(ScrollPhysics::CLAMPING, 400.0, 400.0, -6000.0);
3395        assert_eq!(rest, 0.0, "stopped dead at the start, at {rest}");
3396    }
3397
3398    #[test]
3399    fn a_clamping_fling_that_fits_lands_where_the_platform_says() {
3400        // 1000 px/s covers about 194 px under the platform's spline.
3401        let rest = settle(ScrollPhysics::CLAMPING, 4000.0, 0.0, 1000.0);
3402        assert!((rest - 194.0).abs() < 3.0, "landed at {rest}");
3403    }
3404
3405    #[test]
3406    fn a_bouncing_fling_overshoots_then_settles_back_on_the_edge() {
3407        let id = WidgetId::ROOT;
3408        let physics = ScrollPhysics::BOUNCING;
3409        let mut rt = Runtime::default();
3410        let area = region_with(physics, id, 400.0);
3411        rt.scroll.insert(id, (0.0, 300.0));
3412        rt.fling_scroll(area, physics, (0.0, 4000.0));
3413        let mut peak: f32 = 0.0;
3414        for _ in 0..1200 {
3415            let moving = rt.advance_scroll(&[area], 1.0 / 60.0);
3416            peak = peak.max(rt.scroll.get(&id).copied().unwrap().1);
3417            if !moving {
3418                break;
3419            }
3420        }
3421        assert!(
3422            peak > 400.0,
3423            "it should have gone past the end, peak {peak}"
3424        );
3425        let rest = rt.scroll.get(&id).copied().unwrap().1;
3426        assert!(
3427            (rest - 400.0).abs() < 1.0,
3428            "came back to the end, at {rest}"
3429        );
3430    }
3431
3432    #[test]
3433    fn an_overscrolled_offset_comes_home_even_without_a_fling() {
3434        // A release too slow to fling still owes the content its edge back.
3435        for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::CLAMPING] {
3436            let rest = settle(physics, 400.0, 460.0, 5.0);
3437            assert!(
3438                (rest - 400.0).abs() < 1.0,
3439                "{physics:?} left the content at {rest}"
3440            );
3441        }
3442    }
3443
3444    #[test]
3445    fn a_release_with_nothing_to_do_starts_no_fling() {
3446        let id = WidgetId::ROOT;
3447        let mut rt = Runtime::default();
3448        rt.scroll.insert(id, (0.0, 100.0));
3449        assert!(!rt.fling_scroll(region(id, 400.0), ScrollPhysics::CLAMPING, (0.0, 5.0)));
3450        assert!(rt.scroll_ballistic.is_empty());
3451    }
3452
3453    #[test]
3454    fn a_held_region_is_not_dragged_back_by_the_spring() {
3455        // The bug the device found: while a finger holds an overscrolled offset,
3456        // the edge spring kept retracting it between two moves, so a rubber band
3457        // was pulled back as fast as it was stretched and never appeared.
3458        let id = WidgetId::ROOT;
3459        let physics = ScrollPhysics::BOUNCING;
3460        let area = region_with(physics, id, 400.0);
3461        let pulled = -60.0;
3462
3463        let mut held = Runtime::default();
3464        held.hold_scroll(id);
3465        held.scroll.insert(id, (0.0, pulled));
3466        held.scroll_target.insert(id, (0.0, pulled));
3467        for _ in 0..10 {
3468            held.advance_scroll(&[area], 1.0 / 60.0);
3469        }
3470        assert_eq!(
3471            held.scroll.get(&id).copied().unwrap().1,
3472            pulled,
3473            "a held offset must not move on its own"
3474        );
3475
3476        // The same offset, let go: now it must come home.
3477        let mut free = Runtime::default();
3478        free.scroll.insert(id, (0.0, pulled));
3479        free.scroll_target.insert(id, (0.0, pulled));
3480        while free.advance_scroll(&[area], 1.0 / 60.0) {}
3481        assert!(
3482            free.scroll.get(&id).copied().unwrap().1.abs() < 1.0,
3483            "a released overscroll springs back"
3484        );
3485    }
3486
3487    /// Milestone 533. A fling owns its offset as a finger does: content that shrinks under
3488    /// a bounce does not snap it to the new end in one frame, and when the bounce settles
3489    /// on the end it was aimed at — the old one, now past the content — it springs on to the
3490    /// new end rather than jumping there.
3491    #[test]
3492    fn a_fling_the_content_shrank_under_springs_on_to_the_new_end() {
3493        let id = WidgetId::ROOT;
3494        let physics = ScrollPhysics::BOUNCING;
3495        let mut rt = Runtime::default();
3496        rt.scroll.insert(id, (0.0, 300.0));
3497        assert!(rt.fling_scroll(region_with(physics, id, 400.0), physics, (0.0, 3000.0)));
3498
3499        let shrunk = [region_with(physics, id, 100.0)];
3500        let mut previous = 300.0;
3501        let mut biggest_step: f32 = 0.0;
3502        let mut frames = 0;
3503        while rt.advance_scroll(&shrunk, 1.0 / 60.0) {
3504            let y = rt.scroll.get(&id).copied().unwrap().1;
3505            biggest_step = biggest_step.max((y - previous).abs());
3506            previous = y;
3507            frames += 1;
3508            assert!(frames < 2000, "the motion must end");
3509        }
3510        let y = rt.scroll.get(&id).copied().unwrap().1;
3511        biggest_step = biggest_step.max((y - previous).abs());
3512        assert!((y - 100.0).abs() < 1.0, "home at the new end, at {y}");
3513        assert!(
3514            biggest_step < 120.0,
3515            "carried there, not jumped: {biggest_step} px in one frame"
3516        );
3517        assert!(rt.scroll_ballistic.is_empty() && rt.scroll_target.is_empty());
3518    }
3519
3520    /// Milestone 533. A glide still under way — a wheel's, or a slow release's — is left to
3521    /// its own spring, which already pulls a target past the end back inside it.
3522    #[test]
3523    fn a_glide_the_content_shrank_under_is_not_snapped() {
3524        let id = WidgetId::ROOT;
3525        let mut rt = Runtime::default();
3526        rt.scroll.insert(id, (0.0, 400.0));
3527        rt.scroll_target.insert(id, (0.0, 400.0));
3528        let shrunk = [region(id, 100.0)];
3529        rt.advance_scroll(&shrunk, 1.0 / 60.0);
3530        let first = rt.scroll.get(&id).copied().unwrap().1;
3531        assert!(first > 300.0, "one frame in, still gliding: {first}");
3532        while rt.advance_scroll(&shrunk, 1.0 / 60.0) {}
3533        let rest = rt.scroll.get(&id).copied().unwrap().1;
3534        assert!((rest - 100.0).abs() < 1.0, "and it glides home: {rest}");
3535    }
3536
3537    /// Milestone 533. Nothing owns the offset, the content shrank: it is corrected at once,
3538    /// both axes, both ends of the range.
3539    #[test]
3540    fn an_offset_nobody_owns_is_kept_inside_the_content() {
3541        let id = WidgetId::ROOT;
3542        let mut rt = Runtime::default();
3543        let area = Scrollable {
3544            max_x: 30.0,
3545            ..region(id, 100.0)
3546        };
3547        rt.scroll.insert(id, (80.0, 400.0));
3548        assert!(rt.keep_scroll_in_range(&[area]), "something was corrected");
3549        assert_eq!(rt.scroll.get(&id), Some(&(30.0, 100.0)));
3550        assert!(
3551            !rt.keep_scroll_in_range(&[area]),
3552            "and nothing is left to correct"
3553        );
3554        rt.scroll.insert(id, (-5.0, -20.0));
3555        rt.keep_scroll_in_range(&[area]);
3556        assert_eq!(rt.scroll.get(&id), Some(&(0.0, 0.0)));
3557        // A region nobody has scrolled gains no entry.
3558        let other = region(id.child(1), 0.0);
3559        assert!(!rt.keep_scroll_in_range(&[other]));
3560        assert!(!rt.scroll.contains_key(&other.id));
3561    }
3562
3563    #[test]
3564    fn releasing_a_region_that_was_never_held_changes_nothing() {
3565        let mut rt = Runtime::default();
3566        rt.hold_scroll(WidgetId::ROOT);
3567        rt.release_scroll(WidgetId::ROOT.child(1));
3568        assert_eq!(
3569            rt.scroll_held,
3570            Some(WidgetId::ROOT),
3571            "another region's release must not steal the hold"
3572        );
3573        rt.release_scroll(WidgetId::ROOT);
3574        assert_eq!(rt.scroll_held, None);
3575    }
3576
3577    #[test]
3578    fn a_clamping_fling_lights_the_edge_it_slams_into() {
3579        let id = WidgetId::ROOT;
3580        let physics = ScrollPhysics::CLAMPING;
3581        let mut rt = Runtime::default();
3582        let area = region_with(physics, id, 400.0);
3583        // Far more momentum than there is content: it will reach the end.
3584        rt.fling_scroll(area, physics, (0.0, 6000.0));
3585        while rt.advance_scroll(&[area], 1.0 / 60.0) {}
3586        let glows = rt
3587            .scroll_glow
3588            .get(&id)
3589            .expect("the edge should have lit up");
3590        assert!(!glows.edge(GlowEdge::Bottom).is_idle(), "the end glows");
3591        assert!(
3592            glows.edge(GlowEdge::Top).is_idle(),
3593            "and only the end — the fling never touched the start"
3594        );
3595        // It is a flash, not a permanent mark.
3596        while rt.advance_glow(1.0 / 60.0) {}
3597        assert!(rt.scroll_glow.is_empty(), "the glow is dropped once quiet");
3598    }
3599
3600    #[test]
3601    fn a_bouncing_fling_lights_nothing() {
3602        // The bounce *is* the feedback: a glow on top of it would say the same
3603        // thing twice.
3604        let id = WidgetId::ROOT;
3605        let physics = ScrollPhysics::BOUNCING;
3606        let mut rt = Runtime::default();
3607        let area = region_with(physics, id, 400.0);
3608        rt.fling_scroll(area, physics, (0.0, 6000.0));
3609        while rt.advance_scroll(&[area], 1.0 / 60.0) {}
3610        assert!(rt.scroll_glow.is_empty());
3611    }
3612
3613    #[test]
3614    fn a_pull_that_was_refused_lights_the_edge_and_fades() {
3615        let id = WidgetId::ROOT;
3616        let mut rt = Runtime::default();
3617        rt.glow_pull(
3618            id,
3619            Some(crate::physics::OverscrollIndicator::Glow),
3620            GlowEdge::Top,
3621            -30.0,
3622            600.0,
3623            150.0,
3624            300.0,
3625        );
3626        assert!(!rt.scroll_glow.get(&id).unwrap().is_idle());
3627        rt.glow_scroll_end(id);
3628        let mut frames = 0;
3629        while rt.advance_glow(1.0 / 60.0) {
3630            frames += 1;
3631            assert!(frames < 200, "the glow never faded");
3632        }
3633        assert!(rt.scroll_glow.is_empty());
3634    }
3635
3636    #[test]
3637    fn a_refusal_of_nothing_lights_nothing() {
3638        let mut rt = Runtime::default();
3639        rt.glow_pull(
3640            WidgetId::ROOT,
3641            Some(crate::physics::OverscrollIndicator::Glow),
3642            GlowEdge::Top,
3643            0.0,
3644            600.0,
3645            150.0,
3646            300.0,
3647        );
3648        assert!(
3649            rt.scroll_glow.is_empty(),
3650            "a zero pull is not an overscroll"
3651        );
3652        assert!(!rt.advance_glow(1.0 / 60.0));
3653    }
3654
3655    /// **An area whose behaviour shows nothing past an edge shows nothing** (milestone
3656    /// 616): neither a pull nor a fling landing on the edge lights or stretches anything,
3657    /// as on iOS and the desktops.
3658    #[test]
3659    fn an_area_with_no_indicator_shows_nothing_past_its_edge() {
3660        let mut rt = Runtime::default();
3661        rt.glow_pull(
3662            WidgetId::ROOT,
3663            None,
3664            GlowEdge::Top,
3665            80.0,
3666            600.0,
3667            150.0,
3668            300.0,
3669        );
3670        rt.glow_absorb(WidgetId::ROOT, None, GlowEdge::Bottom, 4000.0);
3671        assert!(rt.scroll_glow.is_empty() && rt.scroll_stretch.is_empty());
3672        // And a fling that slams into the edge of such an area leaves nothing either.
3673        let id = WidgetId::ROOT;
3674        let quiet = Scrollable {
3675            overscroll: None,
3676            ..region(id, 400.0)
3677        };
3678        rt.fling_scroll(quiet, ScrollPhysics::CLAMPING, (0.0, 6000.0));
3679        while rt.advance_scroll(&[quiet], 1.0 / 60.0) {}
3680        assert!(rt.scroll_glow.is_empty() && rt.scroll_stretch.is_empty());
3681        // Where the area glows, the same fling does light the edge: the instrument works.
3682        let lit = region(id, 400.0);
3683        rt.scroll.clear();
3684        rt.fling_scroll(lit, ScrollPhysics::CLAMPING, (0.0, 6000.0));
3685        while rt.advance_scroll(&[lit], 1.0 / 60.0) {}
3686        assert!(!rt.scroll_glow.is_empty());
3687    }
3688
3689    #[test]
3690    fn a_fling_on_a_region_that_vanished_is_dropped() {
3691        let id = WidgetId::ROOT;
3692        let mut rt = Runtime::default();
3693        rt.fling_scroll(region(id, 400.0), ScrollPhysics::CLAMPING, (0.0, 2000.0));
3694        assert!(!rt.scroll_ballistic.is_empty());
3695        // The route changed: the scrollable is no longer part of the frame.
3696        assert!(!rt.advance_scroll(&[], 1.0 / 60.0));
3697        assert!(rt.scroll_ballistic.is_empty());
3698    }
3699
3700    #[test]
3701    fn leaving_fades_out_and_clears() {
3702        let mut rt = Runtime::default();
3703        rt.leaving.insert(0, (Vec::new(), 1.0));
3704        assert!(rt.advance_leaving(0.06)); // ~0.5, still running
3705        assert!(!rt.advance_leaving(0.06)); // reaches 0 → cleared
3706        assert!(rt.leaving.is_empty());
3707    }
3708}