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