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