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(®ions, 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(®ions, 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(®ions, 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(®ions, 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}