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, Option<f32>)>,
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 widget.anim_initial(),
900 ));
901 }
902 for (index, child) in widget.children().iter().enumerate() {
903 collect(
904 child.as_ref(),
905 crate::ui::child_id(id, index, child.as_ref()),
906 still,
907 out,
908 );
909 }
910 }
911 let mut targets: Vec<(WidgetId, f32, f32, Curve, Option<f32>)> = Vec::new();
912 collect(root, WidgetId::ROOT, self.still, &mut targets);
913
914 // Forget the values of widgets that have gone.
915 let present: std::collections::HashSet<WidgetId> =
916 targets.iter().map(|(id, ..)| *id).collect();
917 self.values.retain(|id, _| present.contains(id));
918 self.held_values.retain(|id| present.contains(id));
919 self.placed_values.retain(|id| present.contains(id));
920
921 let mut animating = false;
922 for (id, target, duration, curve, initial) in targets {
923 // A finger holds it: nothing moves it until it lets go.
924 if self.held_values.contains(&id) {
925 continue;
926 }
927 let placed = self.placed_values.contains(&id);
928 match self.values.entry(id) {
929 std::collections::hash_map::Entry::Occupied(mut e) => {
930 let v = e.get_mut();
931 // Placed by a finger: straight there, in the time the distance takes.
932 let (duration, curve) = if placed {
933 let distance = if v.to != target {
934 (target - v.current).abs()
935 } else {
936 (v.to - v.from).abs()
937 };
938 (duration * distance.min(1.0), Curve::Linear)
939 } else {
940 (duration, curve)
941 };
942 // New target: rebase the timeline from the current value.
943 if v.to != target {
944 v.from = v.current;
945 v.to = target;
946 v.elapsed = 0.0;
947 }
948 if v.from == v.to {
949 v.current = v.to;
950 } else {
951 v.elapsed += dt;
952 let t = if duration > 0.0 {
953 (v.elapsed / duration).clamp(0.0, 1.0)
954 } else {
955 1.0
956 };
957 v.current = v.from + (v.to - v.from) * curve.transform(t);
958 if t < 1.0 {
959 animating = true;
960 }
961 }
962 if placed && v.current == v.to {
963 self.placed_values.remove(&id);
964 }
965 }
966 std::collections::hash_map::Entry::Vacant(e) => match initial {
967 // Mount, for a widget that plays from a start (`anim_initial`): the
968 // value sets off from there, this frame counted as the first.
969 Some(start) if start != target && duration > 0.0 => {
970 let t = (dt / duration).clamp(0.0, 1.0);
971 e.insert(ValueAnim {
972 current: start + (target - start) * curve.transform(t),
973 from: start,
974 to: target,
975 elapsed: dt,
976 });
977 animating |= t < 1.0;
978 }
979 // Mount: adopts the target with no transition.
980 _ => {
981 e.insert(ValueAnim::settled(target));
982 }
983 },
984 }
985 }
986 animating
987 }
988
989 /// A widget's animated background colour, if in transition (`None` otherwise).
990 pub fn anim_color(&self, id: WidgetId) -> Option<Color> {
991 self.colors.get(&id).map(|c| c.current)
992 }
993
994 /// Drives every animated background colour towards the target its widget
995 /// declares (`Widget::anim_color`), following its duration/curve
996 /// (`anim_duration`/`anim_curve`). On mount: adopts the target with no
997 /// transition. Returns `true` if a colour is still moving. Same model as
998 /// [`Self::advance_values`].
999 pub fn advance_colors<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1000 fn collect<Msg>(
1001 widget: &dyn crate::widget::Widget<Msg>,
1002 id: WidgetId,
1003 still: bool,
1004 out: &mut Vec<(WidgetId, Color, f32, Curve)>,
1005 ) {
1006 if let Some(target) = widget.anim_color() {
1007 out.push((
1008 id,
1009 target,
1010 if still {
1011 0.0
1012 } else {
1013 widget.anim_duration().max(0.0)
1014 },
1015 widget.anim_curve(),
1016 ));
1017 }
1018 for (index, child) in widget.children().iter().enumerate() {
1019 collect(
1020 child.as_ref(),
1021 crate::ui::child_id(id, index, child.as_ref()),
1022 still,
1023 out,
1024 );
1025 }
1026 }
1027 let mut targets: Vec<(WidgetId, Color, f32, Curve)> = Vec::new();
1028 collect(root, WidgetId::ROOT, self.still, &mut targets);
1029
1030 let present: std::collections::HashSet<WidgetId> =
1031 targets.iter().map(|(id, ..)| *id).collect();
1032 self.colors.retain(|id, _| present.contains(id));
1033
1034 let mut animating = false;
1035 for (id, target, duration, curve) in targets {
1036 match self.colors.entry(id) {
1037 std::collections::hash_map::Entry::Occupied(mut e) => {
1038 let c = e.get_mut();
1039 if c.to != target {
1040 c.from = c.current;
1041 c.to = target;
1042 c.elapsed = 0.0;
1043 }
1044 if c.from == c.to {
1045 c.current = c.to;
1046 } else {
1047 c.elapsed += dt;
1048 let t = if duration > 0.0 {
1049 (c.elapsed / duration).clamp(0.0, 1.0)
1050 } else {
1051 1.0
1052 };
1053 c.current = c.from.lerp(c.to, curve.transform(t));
1054 if t < 1.0 {
1055 animating = true;
1056 }
1057 }
1058 }
1059 std::collections::hash_map::Entry::Vacant(e) => {
1060 e.insert(ColorAnim::settled(target));
1061 }
1062 }
1063 }
1064 animating
1065 }
1066
1067 /// A widget's animated inherited text style, if in transition (`None` otherwise).
1068 ///
1069 /// Read by the theme swap rather than by the node itself: see
1070 /// `crate::ui::scoped_theme`, which is the one place the value is consumed and the
1071 /// reason there is one place at all.
1072 pub fn anim_text_style(&self, id: WidgetId) -> Option<TextStyle> {
1073 self.text_styles.get(&id).map(|s| s.current)
1074 }
1075
1076 /// Drives every animated inherited text style towards the target its widget declares
1077 /// (`Widget::anim_text_style`), following its duration/curve. On mount: adopts the
1078 /// target with no transition. Returns `true` if a style is still moving.
1079 ///
1080 /// Same model as [`Self::advance_colors`], over [`TextStyle::lerp`] — which is where
1081 /// the rules live for what travels, what holds and what swaps at the halfway point.
1082 pub fn advance_text_styles<Msg>(
1083 &mut self,
1084 root: &dyn crate::widget::Widget<Msg>,
1085 dt: f32,
1086 ) -> bool {
1087 fn collect<Msg>(
1088 widget: &dyn crate::widget::Widget<Msg>,
1089 id: WidgetId,
1090 still: bool,
1091 out: &mut Vec<(WidgetId, TextStyle, f32, Curve)>,
1092 ) {
1093 if let Some(target) = widget.anim_text_style() {
1094 out.push((
1095 id,
1096 target,
1097 if still {
1098 0.0
1099 } else {
1100 widget.anim_duration().max(0.0)
1101 },
1102 widget.anim_curve(),
1103 ));
1104 }
1105 for (index, child) in widget.children().iter().enumerate() {
1106 collect(
1107 child.as_ref(),
1108 crate::ui::child_id(id, index, child.as_ref()),
1109 still,
1110 out,
1111 );
1112 }
1113 }
1114 let mut targets: Vec<(WidgetId, TextStyle, f32, Curve)> = Vec::new();
1115 collect(root, WidgetId::ROOT, self.still, &mut targets);
1116
1117 let present: std::collections::HashSet<WidgetId> =
1118 targets.iter().map(|(id, ..)| *id).collect();
1119 self.text_styles.retain(|id, _| present.contains(id));
1120
1121 let mut animating = false;
1122 for (id, target, duration, curve) in targets {
1123 match self.text_styles.entry(id) {
1124 std::collections::hash_map::Entry::Occupied(mut e) => {
1125 let s = e.get_mut();
1126 if s.to != target {
1127 s.from = s.current;
1128 s.to = target;
1129 s.elapsed = 0.0;
1130 }
1131 if s.from == s.to {
1132 s.current = s.to;
1133 } else {
1134 s.elapsed += dt;
1135 let t = if duration > 0.0 {
1136 (s.elapsed / duration).clamp(0.0, 1.0)
1137 } else {
1138 1.0
1139 };
1140 s.current = s.from.lerp(s.to, curve.transform(t));
1141 if t < 1.0 {
1142 animating = true;
1143 }
1144 }
1145 }
1146 std::collections::hash_map::Entry::Vacant(e) => {
1147 e.insert(TextStyleAnim::settled(target));
1148 }
1149 }
1150 }
1151 animating
1152 }
1153
1154 /// A widget's animated size, if in transition (`None` otherwise).
1155 pub fn anim_size(&self, id: WidgetId) -> Option<Size> {
1156 self.sizes.get(&id).map(|s| s.current)
1157 }
1158
1159 /// Drives every animated size towards the target its widget declares
1160 /// (`Widget::anim_size`), following its duration/curve. On mount: adopts the
1161 /// target with no transition. Returns `true` if a size is still moving. Same
1162 /// model as [`Self::advance_values`], except the output is **consumed at
1163 /// layout** (`effective_style`), not at paint.
1164 pub fn advance_sizes<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1165 fn collect<Msg>(
1166 widget: &dyn crate::widget::Widget<Msg>,
1167 id: WidgetId,
1168 still: bool,
1169 out: &mut Vec<(WidgetId, Size, f32, Curve)>,
1170 ) {
1171 if let Some(target) = widget.anim_size() {
1172 out.push((
1173 id,
1174 target,
1175 if still {
1176 0.0
1177 } else {
1178 widget.anim_duration().max(0.0)
1179 },
1180 widget.anim_curve(),
1181 ));
1182 }
1183 for (index, child) in widget.children().iter().enumerate() {
1184 collect(
1185 child.as_ref(),
1186 crate::ui::child_id(id, index, child.as_ref()),
1187 still,
1188 out,
1189 );
1190 }
1191 }
1192 let mut targets: Vec<(WidgetId, Size, f32, Curve)> = Vec::new();
1193 collect(root, WidgetId::ROOT, self.still, &mut targets);
1194
1195 let present: std::collections::HashSet<WidgetId> =
1196 targets.iter().map(|(id, ..)| *id).collect();
1197 self.sizes.retain(|id, _| present.contains(id));
1198
1199 let mut animating = false;
1200 for (id, target, duration, curve) in targets {
1201 match self.sizes.entry(id) {
1202 std::collections::hash_map::Entry::Occupied(mut e) => {
1203 let s = e.get_mut();
1204 if s.to != target {
1205 s.from = s.current;
1206 s.to = target;
1207 s.elapsed = 0.0;
1208 }
1209 if s.from == s.to {
1210 s.current = s.to;
1211 } else {
1212 s.elapsed += dt;
1213 let t = if duration > 0.0 {
1214 (s.elapsed / duration).clamp(0.0, 1.0)
1215 } else {
1216 1.0
1217 };
1218 s.current = lerp_size(s.from, s.to, curve.transform(t));
1219 if t < 1.0 {
1220 animating = true;
1221 }
1222 }
1223 }
1224 std::collections::hash_map::Entry::Vacant(e) => {
1225 e.insert(SizeAnim::settled(target));
1226 }
1227 }
1228 }
1229 animating
1230 }
1231
1232 /// A widget's animated corner radius, if in transition (`None` otherwise).
1233 pub fn anim_radius(&self, id: WidgetId) -> Option<BorderRadius> {
1234 self.radii.get(&id).map(|r| r.current)
1235 }
1236
1237 /// Drives every animated corner radius towards the target its widget declares
1238 /// (`Widget::anim_radius`), following its duration/curve. On mount: adopts the
1239 /// target with no transition. Returns `true` if a radius is still moving. Same
1240 /// model as [`Self::advance_colors`], applied to a [`BorderRadius`].
1241 pub fn advance_radii<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1242 fn collect<Msg>(
1243 widget: &dyn crate::widget::Widget<Msg>,
1244 id: WidgetId,
1245 still: bool,
1246 out: &mut Vec<(WidgetId, BorderRadius, f32, Curve)>,
1247 ) {
1248 if let Some(target) = widget.anim_radius() {
1249 out.push((
1250 id,
1251 target,
1252 if still {
1253 0.0
1254 } else {
1255 widget.anim_duration().max(0.0)
1256 },
1257 widget.anim_curve(),
1258 ));
1259 }
1260 for (index, child) in widget.children().iter().enumerate() {
1261 collect(
1262 child.as_ref(),
1263 crate::ui::child_id(id, index, child.as_ref()),
1264 still,
1265 out,
1266 );
1267 }
1268 }
1269 let mut targets: Vec<(WidgetId, BorderRadius, f32, Curve)> = Vec::new();
1270 collect(root, WidgetId::ROOT, self.still, &mut targets);
1271
1272 let present: std::collections::HashSet<WidgetId> =
1273 targets.iter().map(|(id, ..)| *id).collect();
1274 self.radii.retain(|id, _| present.contains(id));
1275
1276 let mut animating = false;
1277 for (id, target, duration, curve) in targets {
1278 match self.radii.entry(id) {
1279 std::collections::hash_map::Entry::Occupied(mut e) => {
1280 let r = e.get_mut();
1281 if r.to != target {
1282 r.from = r.current;
1283 r.to = target;
1284 r.elapsed = 0.0;
1285 }
1286 if r.from == r.to {
1287 r.current = r.to;
1288 } else {
1289 r.elapsed += dt;
1290 let t = if duration > 0.0 {
1291 (r.elapsed / duration).clamp(0.0, 1.0)
1292 } else {
1293 1.0
1294 };
1295 r.current = lerp_radius(r.from, r.to, curve.transform(t));
1296 if t < 1.0 {
1297 animating = true;
1298 }
1299 }
1300 }
1301 std::collections::hash_map::Entry::Vacant(e) => {
1302 e.insert(RadiusAnim::settled(target));
1303 }
1304 }
1305 }
1306 animating
1307 }
1308
1309 /// A widget's animated transform, if in transition (`None` otherwise).
1310 pub fn anim_transform(&self, id: WidgetId) -> Option<TransformValues> {
1311 self.transforms.get(&id).map(|t| t.current)
1312 }
1313
1314 /// Drives every animated transform towards the target its widget declares
1315 /// (`Widget::anim_transform`), following its duration/curve. On mount: adopts the
1316 /// target with no transition — a widget that appears already scaled does not scale
1317 /// in from nothing. Returns `true` if a transform is still moving. The output is
1318 /// consumed **at paint**, unlike the size and the padding.
1319 pub fn advance_transforms<Msg>(
1320 &mut self,
1321 root: &dyn crate::widget::Widget<Msg>,
1322 dt: f32,
1323 ) -> bool {
1324 fn collect<Msg>(
1325 widget: &dyn crate::widget::Widget<Msg>,
1326 id: WidgetId,
1327 still: bool,
1328 out: &mut Vec<(WidgetId, TransformValues, f32, Curve)>,
1329 ) {
1330 if let Some(target) = widget.anim_transform() {
1331 out.push((
1332 id,
1333 target,
1334 if still {
1335 0.0
1336 } else {
1337 widget.anim_duration().max(0.0)
1338 },
1339 widget.anim_curve(),
1340 ));
1341 }
1342 for (index, child) in widget.children().iter().enumerate() {
1343 collect(
1344 child.as_ref(),
1345 crate::ui::child_id(id, index, child.as_ref()),
1346 still,
1347 out,
1348 );
1349 }
1350 }
1351 let mut targets: Vec<(WidgetId, TransformValues, f32, Curve)> = Vec::new();
1352 collect(root, WidgetId::ROOT, self.still, &mut targets);
1353
1354 let present: std::collections::HashSet<WidgetId> =
1355 targets.iter().map(|(id, ..)| *id).collect();
1356 self.transforms.retain(|id, _| present.contains(id));
1357
1358 let mut animating = false;
1359 for (id, target, duration, curve) in targets {
1360 match self.transforms.entry(id) {
1361 std::collections::hash_map::Entry::Occupied(mut e) => {
1362 let v = e.get_mut();
1363 if v.to != target {
1364 v.from = v.current;
1365 v.to = target;
1366 v.elapsed = 0.0;
1367 }
1368 if v.from == v.to {
1369 v.current = v.to;
1370 } else {
1371 v.elapsed += dt;
1372 let t = if duration > 0.0 {
1373 (v.elapsed / duration).clamp(0.0, 1.0)
1374 } else {
1375 1.0
1376 };
1377 v.current = lerp_transform(v.from, v.to, curve.transform(t));
1378 if t < 1.0 {
1379 animating = true;
1380 }
1381 }
1382 }
1383 std::collections::hash_map::Entry::Vacant(e) => {
1384 e.insert(TransformAnim::settled(target));
1385 }
1386 }
1387 }
1388 animating
1389 }
1390
1391 /// A widget's animated padding, if in transition (`None` otherwise).
1392 pub fn anim_padding(&self, id: WidgetId) -> Option<Insets> {
1393 self.paddings.get(&id).map(|p| p.current)
1394 }
1395
1396 /// A widget's animated offset pair, if in transition (`None` otherwise).
1397 pub fn anim_offset(&self, id: WidgetId) -> Option<(f32, f32)> {
1398 self.offsets.get(&id).map(|o| o.current)
1399 }
1400
1401 /// Drives every animated offset pair towards the target its widget declares
1402 /// (`Widget::anim_offset`), following its duration/curve. On mount: adopts the target
1403 /// with no transition. Returns `true` if one is still moving.
1404 ///
1405 /// The pair belongs to whichever **offset rule** the node declares — its alignment,
1406 /// or its slide — which is why one quantity serves both: a node has one of those
1407 /// rules, and the wrappers in `animated.rs` each give it a node of its own.
1408 pub fn advance_offsets<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1409 fn collect<Msg>(
1410 widget: &dyn crate::widget::Widget<Msg>,
1411 id: WidgetId,
1412 still: bool,
1413 out: &mut Vec<(WidgetId, (f32, f32), f32, Curve)>,
1414 ) {
1415 if let Some(target) = widget.anim_offset() {
1416 out.push((
1417 id,
1418 target,
1419 if still {
1420 0.0
1421 } else {
1422 widget.anim_duration().max(0.0)
1423 },
1424 widget.anim_curve(),
1425 ));
1426 }
1427 for (index, child) in widget.children().iter().enumerate() {
1428 collect(
1429 child.as_ref(),
1430 crate::ui::child_id(id, index, child.as_ref()),
1431 still,
1432 out,
1433 );
1434 }
1435 }
1436 let mut targets: Vec<(WidgetId, (f32, f32), f32, Curve)> = Vec::new();
1437 collect(root, WidgetId::ROOT, self.still, &mut targets);
1438
1439 let present: std::collections::HashSet<WidgetId> =
1440 targets.iter().map(|(id, ..)| *id).collect();
1441 self.offsets.retain(|id, _| present.contains(id));
1442
1443 let mut animating = false;
1444 for (id, target, duration, curve) in targets {
1445 match self.offsets.entry(id) {
1446 std::collections::hash_map::Entry::Occupied(mut e) => {
1447 let o = e.get_mut();
1448 if o.to != target {
1449 o.from = o.current;
1450 o.to = target;
1451 o.elapsed = 0.0;
1452 }
1453 if o.from == o.to {
1454 o.current = o.to;
1455 } else {
1456 o.elapsed += dt;
1457 let t = if duration > 0.0 {
1458 (o.elapsed / duration).clamp(0.0, 1.0)
1459 } else {
1460 1.0
1461 };
1462 let e = curve.transform(t);
1463 o.current = (
1464 o.from.0 + (o.to.0 - o.from.0) * e,
1465 o.from.1 + (o.to.1 - o.from.1) * e,
1466 );
1467 if t < 1.0 {
1468 animating = true;
1469 }
1470 }
1471 }
1472 std::collections::hash_map::Entry::Vacant(e) => {
1473 e.insert(OffsetAnim::settled(target));
1474 }
1475 }
1476 }
1477 animating
1478 }
1479
1480 /// Drives every animated padding towards the target its widget declares
1481 /// (`Widget::anim_padding`), following its duration/curve. On mount: adopts the
1482 /// target with no transition. Returns `true` if a padding is still moving. Like
1483 /// the size, the output is **consumed at layout** (`effective_style`).
1484 pub fn advance_paddings<Msg>(
1485 &mut self,
1486 root: &dyn crate::widget::Widget<Msg>,
1487 dt: f32,
1488 ) -> bool {
1489 fn collect<Msg>(
1490 widget: &dyn crate::widget::Widget<Msg>,
1491 id: WidgetId,
1492 still: bool,
1493 out: &mut Vec<(WidgetId, Insets, f32, Curve)>,
1494 ) {
1495 if let Some(target) = widget.anim_padding() {
1496 out.push((
1497 id,
1498 target,
1499 if still {
1500 0.0
1501 } else {
1502 widget.anim_duration().max(0.0)
1503 },
1504 widget.anim_curve(),
1505 ));
1506 }
1507 for (index, child) in widget.children().iter().enumerate() {
1508 collect(
1509 child.as_ref(),
1510 crate::ui::child_id(id, index, child.as_ref()),
1511 still,
1512 out,
1513 );
1514 }
1515 }
1516 let mut targets: Vec<(WidgetId, Insets, f32, Curve)> = Vec::new();
1517 collect(root, WidgetId::ROOT, self.still, &mut targets);
1518
1519 let present: std::collections::HashSet<WidgetId> =
1520 targets.iter().map(|(id, ..)| *id).collect();
1521 self.paddings.retain(|id, _| present.contains(id));
1522
1523 let mut animating = false;
1524 for (id, target, duration, curve) in targets {
1525 match self.paddings.entry(id) {
1526 std::collections::hash_map::Entry::Occupied(mut e) => {
1527 let p = e.get_mut();
1528 if p.to != target {
1529 p.from = p.current;
1530 p.to = target;
1531 p.elapsed = 0.0;
1532 }
1533 if p.from == p.to {
1534 p.current = p.to;
1535 } else {
1536 p.elapsed += dt;
1537 let t = if duration > 0.0 {
1538 (p.elapsed / duration).clamp(0.0, 1.0)
1539 } else {
1540 1.0
1541 };
1542 p.current = lerp_insets(p.from, p.to, curve.transform(t));
1543 if t < 1.0 {
1544 animating = true;
1545 }
1546 }
1547 }
1548 std::collections::hash_map::Entry::Vacant(e) => {
1549 e.insert(PaddingAnim::settled(target));
1550 }
1551 }
1552 }
1553 animating
1554 }
1555
1556 /// A widget's animated stack pins, if it has any (`None` otherwise).
1557 pub fn anim_pins(&self, id: WidgetId) -> Option<crate::positioned::Positioning> {
1558 self.pins.get(&id).map(|p| {
1559 let [left, top, right, bottom, width, height, start, end] = p.current;
1560 crate::positioned::Positioning {
1561 left,
1562 top,
1563 right,
1564 bottom,
1565 width,
1566 height,
1567 start,
1568 end,
1569 }
1570 })
1571 }
1572
1573 /// A widget's animated fractions of its parent, if it has any (`None` otherwise), as
1574 /// `(width factor, height factor)`.
1575 pub fn anim_fractions(&self, id: WidgetId) -> Option<(Option<f32>, Option<f32>)> {
1576 self.fractions
1577 .get(&id)
1578 .map(|f| (f.current[0], f.current[1]))
1579 }
1580
1581 /// Drives every animated set of stack pins towards the target its widget declares
1582 /// (`Widget::anim_pins`), following its duration/curve. On mount: adopts the target
1583 /// with no transition. Returns `true` if a layer is still moving.
1584 ///
1585 /// The output is consumed where the **stack** lays its layers out, which is the only
1586 /// place pins mean anything — a pin is a request to a parent of one particular kind,
1587 /// not a property of the box.
1588 pub fn advance_pins<Msg>(&mut self, root: &dyn crate::widget::Widget<Msg>, dt: f32) -> bool {
1589 let mut targets = Vec::new();
1590 collect_slots(root, WidgetId::ROOT, self.still, &mut targets, |w| {
1591 w.anim_pins().map(|p| {
1592 [
1593 p.left, p.top, p.right, p.bottom, p.width, p.height, p.start, p.end,
1594 ]
1595 })
1596 });
1597 advance_slot_family(&mut self.pins, targets, dt)
1598 }
1599
1600 /// Drives every animated pair of fractions towards the target its widget declares
1601 /// (`Widget::anim_fractions`), following its duration/curve. On mount: adopts the
1602 /// target with no transition. Returns `true` if a box is still growing or shrinking.
1603 ///
1604 /// Like the size and the padding, the output is **consumed at layout**
1605 /// (`effective_style`): a share of the parent is a claim on room, and a claim on room
1606 /// is settled before anything is drawn.
1607 pub fn advance_fractions<Msg>(
1608 &mut self,
1609 root: &dyn crate::widget::Widget<Msg>,
1610 dt: f32,
1611 ) -> bool {
1612 let mut targets = Vec::new();
1613 collect_slots(root, WidgetId::ROOT, self.still, &mut targets, |w| {
1614 w.anim_fractions().map(|(width, height)| [width, height])
1615 });
1616 advance_slot_family(&mut self.fractions, targets, dt)
1617 }
1618
1619 /// Advances the transitions (hover/focus/press) by `dt` seconds towards their
1620 /// targets. Returns `true` if at least one animation is still running.
1621 pub fn advance(&mut self, dt: f32) -> bool {
1622 let hovered = self.input.hovered;
1623 let focused = self.input.focused;
1624 // Held **and** still under the pointer, which is what makes it a press: the same
1625 // rule `InputState::status_for` applies, so the progression and the flag can
1626 // never disagree about what is happening.
1627 let held = self.input.pressed.filter(|id| Some(*id) == hovered);
1628 if let Some(id) = hovered {
1629 self.anims.entry(id).or_default();
1630 }
1631 if let Some(id) = focused {
1632 self.anims.entry(id).or_default();
1633 }
1634 if let Some(id) = held {
1635 self.anims.entry(id).or_default();
1636 }
1637
1638 let step = if ANIM_DURATION > 0.0 {
1639 dt / ANIM_DURATION
1640 } else {
1641 1.0
1642 };
1643 // The press runs on its own, slower clock (`ink_well.dart:995`).
1644 let press_step = if PRESS_DURATION > 0.0 {
1645 dt / PRESS_DURATION
1646 } else {
1647 1.0
1648 };
1649 let mut animating = false;
1650
1651 self.anims.retain(|id, anim| {
1652 let hover_target = if Some(*id) == hovered { 1.0 } else { 0.0 };
1653 let focus_target = if Some(*id) == focused { 1.0 } else { 0.0 };
1654 let press_target = if Some(*id) == held { 1.0 } else { 0.0 };
1655 approach(&mut anim.hover, hover_target, step, &mut animating);
1656 approach(&mut anim.focus, focus_target, step, &mut animating);
1657 approach(&mut anim.press, press_target, press_step, &mut animating);
1658 // Opacity always tends towards 1 (the fade-in).
1659 approach(&mut anim.opacity, 1.0, step, &mut animating);
1660 // Entries that are entirely at rest are forgotten (nothing to animate). A
1661 // press still fading **out** is not at rest: forgetting it here would be the
1662 // jump this progression exists to remove, on the way back.
1663 !(hover_target == 0.0
1664 && focus_target == 0.0
1665 && press_target == 0.0
1666 && anim.hover <= 0.0
1667 && anim.focus <= 0.0
1668 && anim.press <= 0.0
1669 && anim.opacity >= 1.0)
1670 });
1671 // The boxes following their child's size (milestone 594), here so that everything
1672 // that advances the runtime's transitions advances them too.
1673 animating |= self.size_anims.get_mut().advance(dt, self.still);
1674
1675 animating
1676 }
1677
1678 /// Advances every scroll region by `dt`, and returns `true` if any is still
1679 /// moving.
1680 ///
1681 /// Two motions live here, and they are not the same thing:
1682 ///
1683 /// - a **fling**, when the finger let go with speed: the region's physics built
1684 /// a simulation ([`ScrollBallistic`]), and we sample it. This is where the
1685 /// platform's feel lives — spline deceleration that stops at the edge, or
1686 /// friction that hands over to a spring and bounces.
1687 /// - a **glide to a target**, for the discrete inputs (the wheel, a programmatic
1688 /// scroll): a spring eases the offset across to where it was asked to go.
1689 ///
1690 /// A fling wins while it runs: it drives the offset directly and keeps the
1691 /// target in step, so the spring has nothing to pull against.
1692 ///
1693 /// `regions` describes the scrollables of the last frame, each with the physics its
1694 /// scroll behaviour settled on when it was registered (milestone 616).
1695 pub fn advance_scroll(&mut self, regions: &[Scrollable], dt: f32) -> bool {
1696 // Content that shrank under an offset nobody owns is brought back first, so that
1697 // everything below starts from where the content now is (milestone 533).
1698 self.keep_scroll_in_range(regions);
1699 let mut animating = false;
1700 let ballistic_ids: Vec<WidgetId> = self.scroll_ballistic.keys().copied().collect();
1701 for id in ballistic_ids {
1702 if self.advance_ballistic(id, regions, dt) {
1703 animating = true;
1704 }
1705 }
1706
1707 let ids: Vec<WidgetId> = self.scroll_target.keys().copied().collect();
1708 for id in ids {
1709 // Under a fling this region's offset is already settled for the frame.
1710 if self.scroll_ballistic.contains_key(&id) {
1711 continue;
1712 }
1713 // Under a finger it belongs to the finger, full stop.
1714 if self.scroll_held == Some(id) {
1715 continue;
1716 }
1717 let area = regions.iter().find(|area| area.id == id);
1718 let (max_x, max_y) = area.map(|a| (a.max_x, a.max_y)).unwrap_or((0.0, 0.0));
1719 let current = self.scroll.get(&id).copied().unwrap_or((0.0, 0.0));
1720 let target = self.scroll_target.get(&id).copied().unwrap_or(current);
1721 let vel = self.scroll_velocity.get(&id).copied().unwrap_or((0.0, 0.0));
1722
1723 let (cx, vx, tx, ax) = scroll_axis(current.0, vel.0, target.0, max_x, dt);
1724 let (cy, vy, ty, ay) = scroll_axis(current.1, vel.1, target.1, max_y, dt);
1725
1726 self.scroll.insert(id, (cx, cy));
1727 if ax || ay {
1728 self.scroll_target.insert(id, (tx, ty));
1729 self.scroll_velocity.insert(id, (vx, vy));
1730 animating = true;
1731 } else {
1732 // At rest: the animation state is cleared (the current offset stays).
1733 self.scroll_target.remove(&id);
1734 self.scroll_velocity.remove(&id);
1735 }
1736 }
1737 animating | self.advance_scrollbars(regions, dt)
1738 }
1739
1740 /// Advances every area's **scrollbar fade** by `dt`, and returns `true` while any of
1741 /// them has something left to do — including simply *waiting* to start going.
1742 ///
1743 /// The bar arrives on movement and leaves `BAR_TIME_TO_FADE` after it stops. Nothing
1744 /// reports that movement: the offset is written from the drag handler, from the
1745 /// fling and from the spring, and none of the three leaves word. So this compares
1746 /// the offset against the one it saw last frame, which catches every writer there
1747 /// is, and every writer there will be.
1748 fn advance_scrollbars(&mut self, regions: &[Scrollable], dt: f32) -> bool {
1749 self.scrollbar_fade
1750 .retain(|id, _| regions.iter().any(|area| area.id == *id));
1751 let mut animating = false;
1752 for area in regions {
1753 let now = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
1754 let hovered = self.scrollbar_hovered == Some(area.id);
1755 let held = hovered || self.scrollbar_dragged == Some(area.id);
1756 let fade = self.scrollbar_fade.entry(area.id).or_insert(ScrollbarFade {
1757 seen: now,
1758 // An area **nobody has moved yet** has no bar: the reference's fade
1759 // starts closed and is only ever opened by a scroll notification. A
1760 // list that has just appeared shows its content, not its furniture.
1761 since: BAR_TIME_TO_FADE,
1762 ..ScrollbarFade::default()
1763 });
1764 if fade.seen != now {
1765 fade.seen = now;
1766 fade.since = 0.0;
1767 } else if held {
1768 fade.since = 0.0;
1769 } else {
1770 fade.since += dt;
1771 }
1772 let waiting = fade.since < BAR_TIME_TO_FADE;
1773 let target = if waiting { 1.0 } else { 0.0 };
1774 approach(&mut fade.opacity, target, dt / BAR_FADE, &mut animating);
1775 let warm = if hovered { 1.0 } else { 0.0 };
1776 approach(&mut fade.hover, warm, dt / BAR_HOVER, &mut animating);
1777 // The wait is not a movement, but it still needs a frame at the end of it,
1778 // or the bar would sit there until something else happened to ask for one.
1779 if waiting && !held {
1780 animating = true;
1781 }
1782 }
1783 animating
1784 }
1785
1786 /// What `id`'s scrollbar looks like this frame. An area nothing has touched, or one
1787 /// rendered in isolation with no runtime behind it, answers a closed fade.
1788 pub fn scrollbar_fade_of(&self, id: WidgetId) -> ScrollbarFade {
1789 self.scrollbar_fade.get(&id).copied().unwrap_or_default()
1790 }
1791
1792 /// Samples one region's fling for this frame. Returns `true` while it runs.
1793 ///
1794 /// An axis ends either because its simulation says so, or because it hit an edge
1795 /// the physics does not let it cross — under clamping physics the simulation
1796 /// knows nothing of the bounds, so reaching one **stops that axis dead**, which
1797 /// is exactly the behaviour a platform without a bounce wants.
1798 fn advance_ballistic(&mut self, id: WidgetId, regions: &[Scrollable], dt: f32) -> bool {
1799 let Some(mut fling) = self.scroll_ballistic.get(&id).copied() else {
1800 return false;
1801 };
1802 // A region that vanished from the frame (a route changed under the fling)
1803 // has nothing left to move.
1804 let Some(area) = regions.iter().find(|area| area.id == id).copied() else {
1805 self.scroll_ballistic.remove(&id);
1806 return false;
1807 };
1808 let physics = area.physics;
1809 fling.elapsed += dt;
1810 let t = fling.elapsed;
1811 let current = self.scroll.get(&id).copied().unwrap_or((0.0, 0.0));
1812
1813 // An axis that slams into an edge reports the speed it lost there, so the
1814 // glow can show what the clamp swallowed.
1815 let mut absorbed: Vec<(GlowEdge, f32)> = Vec::new();
1816 let mut axis = |sim: &mut Option<Ballistic>, previous: f32, max: f32, vertical: bool| {
1817 let Some(active) = sim.as_ref() else {
1818 return previous;
1819 };
1820 let mut position = active.x(t);
1821 let mut finished = active.is_done(t);
1822 if !physics.allows_overscroll() {
1823 let pinned = position.clamp(0.0, max);
1824 if pinned != position {
1825 // Hitting the edge is the end of the motion, not a pause at it.
1826 finished = true;
1827 absorbed.push((edge_for(vertical, position - pinned), active.dx(t)));
1828 }
1829 position = pinned;
1830 }
1831 if finished {
1832 *sim = None;
1833 }
1834 position
1835 };
1836
1837 let x = axis(&mut fling.x, current.0, area.max_x, false);
1838 let y = axis(&mut fling.y, current.1, area.max_y, true);
1839 for (edge, velocity) in absorbed {
1840 self.glow_absorb(id, area.overscroll, edge, velocity);
1841 }
1842 self.scroll.insert(id, (x, y));
1843 // The glide-to-target path must not fight the fling, nor yank the offset
1844 // back when the fling ends.
1845 self.scroll_target.insert(id, (x, y));
1846 self.scroll_velocity.remove(&id);
1847
1848 if fling.is_empty() {
1849 self.scroll_ballistic.remove(&id);
1850 self.scroll_target.remove(&id);
1851 // Settled on an end the content no longer reaches — it shrank under the fling,
1852 // whose simulation was built for the old one. Spring on to the end there is now:
1853 // left to the correction at rest, the offset would jump there in one frame. The
1854 // reference starts its ballistic again on new dimensions for the same reason.
1855 let inside = inside_extent((x, y), (area.max_x, area.max_y));
1856 let slack = physics.tolerance().distance;
1857 if (x - inside.0).abs() > slack || (y - inside.1).abs() > slack {
1858 return self.fling_scroll(area, physics, (0.0, 0.0));
1859 }
1860 false
1861 } else {
1862 self.scroll_ballistic.insert(id, fling);
1863 true
1864 }
1865 }
1866
1867 /// Launches a fling on `area` from the release `velocity` (px/s, in scroll
1868 /// space), under `physics`. A release too slow to fling leaves any overscroll to
1869 /// be sprung back, and otherwise does nothing.
1870 pub fn fling_scroll(
1871 &mut self,
1872 area: Scrollable,
1873 physics: ScrollPhysics,
1874 velocity: (f32, f32),
1875 ) -> bool {
1876 let current = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
1877 // A paged area does not fling: it goes to **a** page, and the release speed
1878 // only says which one. The raw velocity is passed on rather than the
1879 // fling-filtered one, because the question a page view asks of a release is
1880 // "which way did it go", to which 60 px/s is as clear an answer as 2000.
1881 //
1882 // Unless it has been told not to snap, in which case it is a scrollable that
1883 // happens to know where its pages are: it still reports them and still glides
1884 // to the one it is asked for, and only the release falls through to the
1885 // ordinary fling below.
1886 if let Some(snap) = area.page.filter(|snap| snap.snapping) {
1887 let (metrics, velocity) = if snap.horizontal {
1888 (area.metrics_x(current.0), velocity.0)
1889 } else {
1890 (area.metrics_y(current.1), velocity.1)
1891 };
1892 let motion = physics.page_ballistic(metrics, velocity, snap.extent);
1893 if motion.is_none() {
1894 return false;
1895 }
1896 let ballistic = if snap.horizontal {
1897 ScrollBallistic::new(motion, None)
1898 } else {
1899 ScrollBallistic::new(None, motion)
1900 };
1901 self.scroll_ballistic.insert(area.id, ballistic);
1902 self.scroll_velocity.remove(&area.id);
1903 return true;
1904 }
1905 let min_velocity = physics.min_fling_velocity();
1906 // Below the threshold the gesture was not a fling — but an offset left out
1907 // of range still has to come home, so the physics is asked either way, with
1908 // a velocity of zero.
1909 let vx = if velocity.0.abs() >= min_velocity {
1910 velocity.0
1911 } else {
1912 0.0
1913 };
1914 let vy = if velocity.1.abs() >= min_velocity {
1915 velocity.1
1916 } else {
1917 0.0
1918 };
1919 let x = physics.ballistic(area.metrics_x(current.0), vx);
1920 let y = physics.ballistic(area.metrics_y(current.1), vy);
1921 if x.is_none() && y.is_none() {
1922 return false;
1923 }
1924 self.scroll_ballistic
1925 .insert(area.id, ScrollBallistic::new(x, y));
1926 self.scroll_velocity.remove(&area.id);
1927 true
1928 }
1929
1930 /// Acts on the page each paged view is **asking** for.
1931 ///
1932 /// A request is honoured when it *changes*, never re-asserted: the widget is
1933 /// rebuilt every frame and carries the same number each time, so a view that
1934 /// obeyed it on every frame could not be swiped at all — the finger would move
1935 /// the offset and the next frame would put it straight back.
1936 ///
1937 /// The **first** sighting of a region is its initial page, and arrives without
1938 /// an animation: an application opening on page 3 wants to be on page 3, not to
1939 /// watch it fly there.
1940 pub fn sync_pages(&mut self, regions: &[Scrollable]) {
1941 for area in regions {
1942 let Some(snap) = area.page else { continue };
1943 let previous = self.page_requested.insert(area.id, snap.requested);
1944 if previous == Some(snap.requested) {
1945 continue;
1946 }
1947 let max = if snap.horizontal {
1948 area.max_x
1949 } else {
1950 area.max_y
1951 };
1952 let offset = snap
1953 .offset_of(snap.requested.min(snap.count.saturating_sub(1)))
1954 .clamp(0.0, max);
1955 let current = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
1956 let target = if snap.horizontal {
1957 (offset, current.1)
1958 } else {
1959 (current.0, offset)
1960 };
1961 match previous {
1962 // Never seen: this is where the view opens.
1963 None => {
1964 self.scroll.insert(area.id, target);
1965 self.scroll_target.remove(&area.id);
1966 self.scroll_velocity.remove(&area.id);
1967 }
1968 // A change under way: glide, and let go of anything already moving
1969 // the offset — the application has just overruled it.
1970 Some(_) => {
1971 self.scroll_ballistic.remove(&area.id);
1972 self.scroll_target.insert(area.id, target);
1973 }
1974 }
1975 }
1976 }
1977
1978 /// Brings each region's **kept** box into view when it has changed.
1979 ///
1980 /// A tab bar wider than its window is what this is for: the application selects the
1981 /// eighth tab and the bar is still showing the first three, with nothing to say the
1982 /// selection went anywhere.
1983 ///
1984 /// Acted on **when the key changes**, never re-asserted. The box moves as the region
1985 /// scrolls, so a region that chased the box itself would pin the strip in place and
1986 /// no finger could move it — the same trap [`Runtime::sync_pages`] avoids, and the
1987 /// same answer.
1988 ///
1989 /// The **first** sighting arrives without an animation — a bar restored on its
1990 /// eighth tab wants to be on the eighth tab, not to watch it slide there — and the
1991 /// walk has already drawn that first frame at the same offset, so the jump is
1992 /// invisible. It is retained here all the same: without it the region would have no
1993 /// offset of its own, and the frame after would find nothing to keep it there.
1994 pub fn sync_visible(&mut self, regions: &[Scrollable]) {
1995 for area in regions {
1996 let Some(keep) = area.keep_visible else {
1997 continue;
1998 };
1999 let previous = self.kept_visible.insert(area.id, keep.key);
2000 if previous == Some(keep.key) {
2001 continue;
2002 }
2003 let (offset, _) = area.keep_offset(keep);
2004 match previous {
2005 // Never seen: this is where the region opens.
2006 None => {
2007 self.scroll.insert(area.id, offset);
2008 self.scroll_target.remove(&area.id);
2009 self.scroll_velocity.remove(&area.id);
2010 }
2011 // A change: glide, and let go of anything already moving the offset,
2012 // since the selection has just overruled it.
2013 Some(_) => {
2014 self.scroll_ballistic.remove(&area.id);
2015 self.scroll_target.insert(area.id, offset);
2016 }
2017 }
2018 }
2019 }
2020
2021 /// The paged views whose page **on screen** has just changed, and the page each
2022 /// now shows.
2023 ///
2024 /// Reported as soon as the rounding tips — mid-drag, not once the spring has
2025 /// settled — because that is when a reader would say they are on the next page.
2026 /// A caller turns each into a message; the runtime holds no messages of its own.
2027 ///
2028 /// A view **appearing** is not a page change: the first sighting of a region is
2029 /// recorded silently, whatever page it opens on. An application that opens on
2030 /// page 3 already knows it is on page 3, and being told so on the first frame
2031 /// would only invite it to answer.
2032 pub fn page_changes(&mut self, regions: &[Scrollable]) -> Vec<(WidgetId, usize)> {
2033 let mut changed = Vec::new();
2034 for area in regions {
2035 let Some(snap) = area.page else { continue };
2036 let offset = self.scroll.get(&area.id).copied().unwrap_or_else(|| {
2037 let start = snap.offset_of(snap.requested);
2038 if snap.horizontal {
2039 (start, 0.0)
2040 } else {
2041 (0.0, start)
2042 }
2043 });
2044 let page = snap.page_at(if snap.horizontal { offset.0 } else { offset.1 });
2045 match self.page_shown.insert(area.id, page) {
2046 Some(previous) if previous != page => changed.push((area.id, page)),
2047 _ => {}
2048 }
2049 }
2050 changed
2051 }
2052
2053 /// Is region `id` **still**? Nothing is driving it: no finger, no fling, and no
2054 /// spring left to run.
2055 ///
2056 /// The three writers of an offset, asked in one place. A caller that asked only two
2057 /// of them would call a region at rest in the middle of a fling.
2058 pub fn scroll_at_rest(&self, id: WidgetId) -> bool {
2059 self.scroll_held != Some(id)
2060 && !self.scroll_ballistic.contains_key(&id)
2061 && !self.scroll_target.contains_key(&id)
2062 }
2063
2064 /// Where region `id` is **drawn**, its content now reaching no further than `max`: the
2065 /// retained offset, brought inside `[0, max]` when nothing owns it. `None` when it has
2066 /// never been scrolled.
2067 ///
2068 /// The walk reads this rather than the map because the content can shrink in the very
2069 /// frame being built — a row of labels at a smaller text size — and that frame should
2070 /// already show the content where it rests, as the reference corrects its position
2071 /// during layout. [`Runtime::keep_scroll_in_range`] retains the same answer afterwards.
2072 /// A finger, a fling or a glide keeps what it has: each of them owns the offset, and
2073 /// each already brings it home when it lets go.
2074 pub fn scroll_offset_within(&self, id: WidgetId, max: (f32, f32)) -> Option<(f32, f32)> {
2075 let offset = self.scroll.get(&id).copied()?;
2076 Some(if self.scroll_at_rest(id) {
2077 inside_extent(offset, max)
2078 } else {
2079 offset
2080 })
2081 }
2082
2083 /// Brings every region **nobody owns** back inside its content, and answers whether any
2084 /// had strayed.
2085 ///
2086 /// An offset at rest can only be out of range because the content or the viewport
2087 /// changed under it: a drag, a fling and a glide all settle inside. Found on a phone in
2088 /// milestone 533 — a row of filters swiped to its end at a large text size stayed
2089 /// swiped once the text was small again and the row fitted, its first label cut at the
2090 /// left and empty room at the right.
2091 ///
2092 /// Corrected at once rather than sprung, as the reference's default physics does for a
2093 /// position that was in range and is not animating. The scrollbar is not summoned for
2094 /// it: nothing scrolled, the content changed.
2095 pub fn keep_scroll_in_range(&mut self, regions: &[Scrollable]) -> bool {
2096 let mut corrected = false;
2097 for area in regions {
2098 let Some(offset) = self.scroll.get(&area.id).copied() else {
2099 continue;
2100 };
2101 let inside = inside_extent(offset, (area.max_x, area.max_y));
2102 if inside == offset || !self.scroll_at_rest(area.id) {
2103 continue;
2104 }
2105 self.scroll.insert(area.id, inside);
2106 if let Some(fade) = self
2107 .scrollbar_fade
2108 .get_mut(&area.id)
2109 .filter(|fade| fade.seen == offset)
2110 {
2111 fade.seen = inside;
2112 }
2113 corrected = true;
2114 }
2115 corrected
2116 }
2117
2118 /// The scroll regions whose offset is worth reporting this frame, and where each is
2119 /// — the regions' half of [`crate::Widget::on_scroll`]. `grain` answers
2120 /// [`crate::Widget::scroll_grain`] for a region; a caller turns each result into a
2121 /// message, the runtime holding none of its own.
2122 ///
2123 /// Three rules, and each of them is a decision:
2124 ///
2125 /// - **A region appearing is not a movement** — as long as it appears where an
2126 /// application would assume it is. A region seen for the first time at rest at
2127 /// `(0, 0)` is recorded silently: telling an application on the first frame of
2128 /// every screen that its list is at the top would only invite it to answer, and it
2129 /// already knew. A region that appears **somewhere else** — restored, or opened at
2130 /// a box it was asked to keep in view — is reported, because that one is news.
2131 /// - **A grain suppresses the reports in between, never the last one.** While the
2132 /// region is moving, nothing under `grain` pixels from the last report is sent; the
2133 /// moment it comes to rest, a position different from the last reported one is sent
2134 /// whatever the distance. A coarse grain therefore costs frames, never accuracy.
2135 /// - **The baseline is what was reported**, not the previous frame. Measuring
2136 /// against the previous frame would let a slow drag creep the whole way down a
2137 /// list, a fraction of a grain at a time, without ever saying so.
2138 pub fn scroll_changes(
2139 &mut self,
2140 regions: &[Scrollable],
2141 grain: impl Fn(WidgetId) -> f32,
2142 ) -> Vec<(WidgetId, crate::scrollposition::ScrollPosition)> {
2143 let mut changed = Vec::new();
2144 for area in regions {
2145 let offset = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
2146 let previous = self.scroll_reported.get(&area.id).copied();
2147 let report = match previous {
2148 None => offset != (0.0, 0.0),
2149 Some(previous) if previous == offset => false,
2150 Some(previous) => {
2151 let grain = grain(area.id).max(0.0);
2152 let moved = (offset.0 - previous.0)
2153 .abs()
2154 .max((offset.1 - previous.1).abs());
2155 moved >= grain || self.scroll_at_rest(area.id)
2156 }
2157 };
2158 if report {
2159 changed.push((
2160 area.id,
2161 crate::scrollposition::ScrollPosition::of(area, offset),
2162 ));
2163 }
2164 if report || previous.is_none() {
2165 self.scroll_reported.insert(area.id, offset);
2166 }
2167 }
2168 self.scroll_reported
2169 .retain(|id, _| regions.iter().any(|area| area.id == *id));
2170 changed
2171 }
2172
2173 /// Moves region `area` where `request` asks. `true` when it was acted on.
2174 ///
2175 /// **A finger refuses it.** An offset has one owner at a time, and while a hand is
2176 /// on the content that owner is the hand — a list that jumped out from under a
2177 /// finger because a timer fired would be the framework overruling the reader.
2178 pub fn scroll_to(
2179 &mut self,
2180 area: &Scrollable,
2181 request: crate::scrollposition::ScrollTo,
2182 ) -> bool {
2183 if request.is_empty() || self.scroll_held == Some(area.id) {
2184 return false;
2185 }
2186 let current = self.scroll.get(&area.id).copied().unwrap_or((0.0, 0.0));
2187 let to = request.resolve(area, current);
2188 // A fling in flight has just been overruled, animated or not: the request is
2189 // the more recent statement of where this list should be.
2190 self.scroll_ballistic.remove(&area.id);
2191 if request.animate {
2192 self.scroll_target.insert(area.id, to);
2193 } else {
2194 self.scroll.insert(area.id, to);
2195 self.scroll_target.remove(&area.id);
2196 self.scroll_velocity.remove(&area.id);
2197 }
2198 true
2199 }
2200
2201 /// A finger takes hold of `id`: from now until [`Runtime::release_scroll`],
2202 /// this region's offset moves only when the finger says so. Any fling in
2203 /// flight is caught, since the finger has just overruled it.
2204 /// Records a change to field `id`, which **was** `before`, `since` seconds after the
2205 /// last one — see [`crate::UndoHistory::record`] for what counts as one step.
2206 pub fn record_edit(
2207 &mut self,
2208 id: WidgetId,
2209 before: crate::undo::EditSnapshot,
2210 kind: crate::undo::EditKind,
2211 since: f32,
2212 ) {
2213 self.undo.entry(id).or_default().record(before, kind, since);
2214 }
2215
2216 /// Ends the run in progress in field `id`: the caret moved, so what is typed next is a
2217 /// step of its own.
2218 pub fn close_edit_run(&mut self, id: WidgetId) {
2219 if let Some(history) = self.undo.get_mut(&id) {
2220 history.close_run();
2221 }
2222 }
2223
2224 /// Steps field `id` back one change, `current` being where it is now. `None` when
2225 /// there is nothing to undo.
2226 pub fn undo_edit(
2227 &mut self,
2228 id: WidgetId,
2229 current: crate::undo::EditSnapshot,
2230 ) -> Option<crate::undo::EditSnapshot> {
2231 self.undo.get_mut(&id)?.undo(current)
2232 }
2233
2234 /// Steps field `id` forward again. `None` when nothing was undone.
2235 pub fn redo_edit(
2236 &mut self,
2237 id: WidgetId,
2238 current: crate::undo::EditSnapshot,
2239 ) -> Option<crate::undo::EditSnapshot> {
2240 self.undo.get_mut(&id)?.redo(current)
2241 }
2242
2243 /// A finger takes hold of the region `id`. A scroll offset has one owner at a time, so
2244 /// nothing else moves it until [`release_scroll`](Self::release_scroll); see
2245 /// [`scroll_held`](Self::scroll_held).
2246 pub fn hold_scroll(&mut self, id: WidgetId) {
2247 self.scroll_held = Some(id);
2248 }
2249
2250 /// The finger lets go: the offset is up for grabs again — a fling, a spring
2251 /// back from an overscroll, or nothing.
2252 pub fn release_scroll(&mut self, id: WidgetId) {
2253 if self.scroll_held == Some(id) {
2254 self.scroll_held = None;
2255 }
2256 }
2257
2258 /// Feeds `overscroll` px of refused movement into the pull of the refresh area
2259 /// `id`, over a scrollable of `extent` px.
2260 ///
2261 /// This is the same measurement the glow is fed — what
2262 /// `apply_boundary_conditions` refused — routed to a different consumer. Where a
2263 /// refresh area is listening, the glow on that edge stands down: two answers to
2264 /// one gesture would say the same thing twice.
2265 pub fn refresh_pull(&mut self, id: WidgetId, overscroll: f32, extent: f32) {
2266 crate::refresh::pull_into(&mut self.refresh, id, overscroll, extent);
2267 }
2268
2269 /// Moves the dismissible `id` by `delta` px along its axis, over an item of
2270 /// `extent` px.
2271 pub fn dismiss_drag(
2272 &mut self,
2273 id: WidgetId,
2274 delta: f32,
2275 extent: f32,
2276 axis: crate::dismiss::DismissAxis,
2277 ) {
2278 crate::dismiss::drag_into(&mut self.dismiss, id, delta, extent, axis);
2279 }
2280
2281 /// The finger lets go of the dismissible `id`. Returns the direction it is being
2282 /// dismissed in, or `None` when it slides back.
2283 pub fn dismiss_release(
2284 &mut self,
2285 id: WidgetId,
2286 velocity: f32,
2287 cross: f32,
2288 axis: crate::dismiss::DismissAxis,
2289 threshold: f32,
2290 ) -> Option<crate::dismiss::DismissDirection> {
2291 crate::dismiss::release_of(&mut self.dismiss, id, velocity, cross, axis, threshold)
2292 }
2293
2294 /// How much of a dismissed item's box is left, while its gap closes. `None` = not
2295 /// collapsing, so the item keeps the size its style asks for.
2296 pub fn dismiss_extent_factor(&self, id: WidgetId) -> Option<f32> {
2297 self.dismiss
2298 .get(&id)
2299 .filter(|s| s.phase() == crate::dismiss::DismissPhase::Collapse)
2300 .map(|s| s.extent_factor())
2301 }
2302
2303 /// Advances every dismissible of the frame by `dt`. Returns `(still animating, the
2304 /// items whose gap has just closed)` — the second being what the shell turns into
2305 /// messages.
2306 pub fn advance_dismiss(
2307 &mut self,
2308 items: &[crate::dismiss::Dismissable],
2309 dt: f32,
2310 ) -> (bool, Vec<(WidgetId, crate::dismiss::DismissDirection)>) {
2311 crate::dismiss::advance_all(&mut self.dismiss, items, dt)
2312 }
2313
2314 /// The height of the sheet `id`, as a share of its box: the retained one, or its
2315 /// initial one while nothing has moved it.
2316 pub fn sheet_size(&self, id: WidgetId, spec: &crate::sheet::SheetSpec) -> f32 {
2317 crate::sheet::size_of(&self.sheets, id, spec)
2318 }
2319
2320 /// Moves the sheet `id` by `delta`, a share of its box — held by the finger until
2321 /// [`Runtime::sheet_release`].
2322 pub fn sheet_drag(
2323 &mut self,
2324 id: WidgetId,
2325 spec: &crate::sheet::SheetSpec,
2326 delta: f32,
2327 available: f32,
2328 ) {
2329 crate::sheet::drag_into(&mut self.sheets, id, spec, delta, available);
2330 }
2331
2332 /// A finger lands on the sheet `id`: a settle in progress stops where it is.
2333 pub fn sheet_hold(&mut self, id: WidgetId) {
2334 crate::sheet::hold_of(&mut self.sheets, id);
2335 }
2336
2337 /// The finger lets go of the sheet `id` at `velocity` px/s, positive growing it, over
2338 /// a box `available` px tall — from `list`, when the finger was on the list inside it,
2339 /// which is then handed whatever is left of a throw that reaches full height.
2340 pub fn sheet_release(
2341 &mut self,
2342 id: WidgetId,
2343 spec: &crate::sheet::SheetSpec,
2344 available: f32,
2345 velocity: f32,
2346 list: Option<WidgetId>,
2347 ) {
2348 crate::sheet::release_of(&mut self.sheets, id, spec, available, velocity, list);
2349 }
2350
2351 /// Asks the sheet `id` for `to` — an application's request, by way of `Command::sheet`.
2352 /// `false` when a finger is on it, which refuses it, as a finger refuses a scroll
2353 /// request.
2354 pub fn sheet_to(
2355 &mut self,
2356 id: WidgetId,
2357 spec: &crate::sheet::SheetSpec,
2358 to: &crate::sheet::SheetTo,
2359 ) -> bool {
2360 crate::sheet::go_to(&mut self.sheets, id, spec, to)
2361 }
2362
2363 /// Advances every sheet of the frame by `dt`. Returns `(still moving, the sheets
2364 /// lowered to nothing on this frame)` — the second being what the shell turns into
2365 /// messages. A throw that reached full height is kept for
2366 /// [`Runtime::take_sheet_handovers`].
2367 pub fn advance_sheets(
2368 &mut self,
2369 areas: &[crate::sheet::SheetArea],
2370 dt: f32,
2371 ) -> (bool, Vec<WidgetId>) {
2372 let step = crate::sheet::advance_all(&mut self.sheets, areas, dt);
2373 self.sheet_handovers.extend(step.handovers);
2374 (step.moving, step.dismissed)
2375 }
2376
2377 /// The throws that carried a sheet to full height since the last call: the list each
2378 /// goes on in, and the velocity to fling it at, in px/s growing its offset.
2379 ///
2380 /// The sheet does not fling the list itself, because a fling is started under the
2381 /// list's physics, and those are the application's to choose.
2382 pub fn take_sheet_handovers(&mut self) -> Vec<(WidgetId, f32)> {
2383 std::mem::take(&mut self.sheet_handovers)
2384 }
2385
2386 /// Calls off the pull of `id` without asking for anything — the list scrolled away
2387 /// from its top edge, or the gesture was cancelled.
2388 pub fn refresh_cancel(&mut self, id: WidgetId) {
2389 crate::refresh::cancel_of(&mut self.refresh, id);
2390 }
2391
2392 /// Ends the pull of `id`. Returns `true` when it was armed, and so when the
2393 /// application should be asked to refresh.
2394 pub fn refresh_release(&mut self, id: WidgetId) -> bool {
2395 crate::refresh::release_of(&mut self.refresh, id)
2396 }
2397
2398 /// Advances every refresh area of the frame by `dt`, reading each one's current
2399 /// `refreshing` flag from `areas`, and drops those that have gone quiet. Returns
2400 /// `true` while any is still moving.
2401 pub fn advance_refresh(&mut self, areas: &[crate::refresh::Refreshable], dt: f32) -> bool {
2402 crate::refresh::advance_all(&mut self.refresh, areas, dt)
2403 }
2404
2405 /// Tells the glow on one edge of `id` that a finger is dragging past it.
2406 ///
2407 /// `overscroll` is the movement the physics **refused** — which is exactly the
2408 /// distance the user asked for and did not get, and so exactly what the glow is
2409 /// there to acknowledge.
2410 ///
2411 /// `indicator` is what the area's scroll behaviour shows past an edge; `None` shows
2412 /// nothing, as on iOS and the desktops (milestone 616).
2413 #[allow(clippy::too_many_arguments)]
2414 pub fn glow_pull(
2415 &mut self,
2416 id: WidgetId,
2417 indicator: Option<crate::physics::OverscrollIndicator>,
2418 edge: GlowEdge,
2419 overscroll: f32,
2420 extent: f32,
2421 cross_offset: f32,
2422 cross_extent: f32,
2423 ) {
2424 let Some(indicator) = indicator else {
2425 return;
2426 };
2427 if overscroll.abs() < 1e-3 {
2428 return;
2429 }
2430 if indicator == crate::physics::OverscrollIndicator::Stretch {
2431 self.scroll_stretch
2432 .entry(id)
2433 .or_default()
2434 .axis_mut(edge)
2435 .pull(overscroll, crate::stretch::is_start(edge), extent);
2436 return;
2437 }
2438 self.scroll_glow.entry(id).or_default().edge_mut(edge).pull(
2439 overscroll,
2440 extent,
2441 cross_offset,
2442 cross_extent,
2443 );
2444 }
2445
2446 /// Tells the glow on one edge of `id` that a fling just landed on it.
2447 pub fn glow_absorb(
2448 &mut self,
2449 id: WidgetId,
2450 indicator: Option<crate::physics::OverscrollIndicator>,
2451 edge: GlowEdge,
2452 velocity: f32,
2453 ) {
2454 let Some(indicator) = indicator else {
2455 return;
2456 };
2457 if indicator == crate::physics::OverscrollIndicator::Stretch {
2458 self.scroll_stretch
2459 .entry(id)
2460 .or_default()
2461 .axis_mut(edge)
2462 .absorb_impact(velocity, crate::stretch::is_start(edge));
2463 return;
2464 }
2465 self.scroll_glow
2466 .entry(id)
2467 .or_default()
2468 .edge_mut(edge)
2469 .absorb_impact(velocity);
2470 }
2471
2472 /// Tells every glow of `id` that the gesture is over, so a held pull can fade.
2473 pub fn glow_scroll_end(&mut self, id: WidgetId) {
2474 if let Some(glows) = self.scroll_glow.get_mut(&id) {
2475 glows.scroll_end();
2476 }
2477 self.stretch_release(id);
2478 }
2479
2480 /// Lets the stretch of `id` go: the content moved again, and the reference releases a
2481 /// stretch as soon as it scrolls (milestone 591).
2482 pub fn stretch_release(&mut self, id: WidgetId) {
2483 if let Some(stretch) = self.scroll_stretch.get_mut(&id) {
2484 stretch.release();
2485 }
2486 }
2487
2488 /// Advances every glow by `dt`, dropping those that have gone quiet. Returns
2489 /// `true` while any is still animating.
2490 pub fn advance_glow(&mut self, dt: f32) -> bool {
2491 let mut animating = false;
2492 self.scroll_stretch.retain(|_, stretch| {
2493 animating |= stretch.advance(dt);
2494 !stretch.is_idle()
2495 });
2496 self.scroll_glow.retain(|_, glows| {
2497 animating |= glows.advance(dt);
2498 !glows.is_idle()
2499 });
2500 animating
2501 }
2502
2503 /// A finger landed on the inked surface `id`, at `origin` in the surface's own
2504 /// coordinates, on a box of `size`. Starts a splash.
2505 pub fn ink_press(&mut self, id: WidgetId, origin: Point, size: Size) {
2506 self.ink.entry(id).or_default().press(origin, size);
2507 }
2508
2509 /// The tap on `id` completed: its splash finishes growing and begins to fade.
2510 pub fn ink_confirm(&mut self, id: WidgetId) {
2511 if let Some(ripples) = self.ink.get_mut(&id) {
2512 ripples.confirm();
2513 }
2514 }
2515
2516 /// Advances every splash by `dt`, dropping those that have gone. Returns `true`
2517 /// while any is still moving.
2518 ///
2519 /// A splash still waiting for a finger that is no longer down is cancelled here
2520 /// rather than left hanging. A release that lands on the widget confirms it first
2521 /// (the shell calls [`Runtime::ink_confirm`] before the next frame); anything else
2522 /// — the finger slid off, the widget was unmounted mid-press, a gesture was taken
2523 /// over by a scroll — leaves nothing to confirm it, and without this sweep the ink
2524 /// would sit there for the life of the application.
2525 pub fn advance_ink(&mut self, dt: f32) -> bool {
2526 if self.ink.is_empty() {
2527 return false;
2528 }
2529 let pressed = self.input.pressed;
2530 let mut animating = false;
2531 self.ink.retain(|id, ripples| {
2532 if Some(*id) != pressed {
2533 ripples.cancel();
2534 }
2535 animating |= ripples.advance(dt);
2536 !ripples.is_idle()
2537 });
2538 animating
2539 }
2540
2541 /// Cancels any fling on `id` — a new gesture, a wheel notch or a programmatic
2542 /// scroll takes precedence over momentum from the last one.
2543 pub fn stop_scroll_fling(&mut self, id: WidgetId) -> Option<ScrollBallistic> {
2544 self.scroll_ballistic.remove(&id)
2545 }
2546 /// Catches a fling under a new press: stops it, and returns the momentum the
2547 /// next release inherits from it, per axis, in px/s.
2548 ///
2549 /// It is what makes repeated swipes accelerate on the platforms that do that;
2550 /// where they do not, [`ScrollPhysics::carried_momentum`] returns zero and this
2551 /// is simply a stop.
2552 pub fn catch_scroll_fling(&mut self, id: WidgetId, physics: ScrollPhysics) -> (f32, f32) {
2553 let Some(fling) = self.scroll_ballistic.remove(&id) else {
2554 return (0.0, 0.0);
2555 };
2556 let carried = |sim: Option<Ballistic>| {
2557 sim.map(|s| physics.carried_momentum(s.dx(fling.elapsed)))
2558 .unwrap_or(0.0)
2559 };
2560 (carried(fling.x), carried(fling.y))
2561 }
2562
2563 /// Advances the pan *fling* of the interactive viewports: each velocity moves the
2564 /// translation (decelerated by exponential friction) and is then **clamped** to its
2565 /// viewport — hitting an edge cancels the velocity on that axis. `viewports` supplies
2566 /// the viewport of each interactive viewer (from the current frame). Returns `true`
2567 /// if a fling is still running.
2568 pub fn advance_interactive(&mut self, viewports: &[(WidgetId, Rect)], dt: f32) -> bool {
2569 use crate::interactive::{PAN_FRICTION, PAN_MIN_VELOCITY};
2570 if self.interactive_velocity.is_empty() {
2571 return false;
2572 }
2573 let ids: Vec<WidgetId> = self.interactive_velocity.keys().copied().collect();
2574 let decay = (-PAN_FRICTION * dt).exp();
2575 let mut animating = false;
2576 for id in ids {
2577 let mut v = self
2578 .interactive_velocity
2579 .get(&id)
2580 .copied()
2581 .unwrap_or((0.0, 0.0));
2582 let mut view = self.interactive.get(&id).copied().unwrap_or_default();
2583 let moved = view.pan(v.0 * dt, v.1 * dt);
2584 // Clamping: an axis that hits a bound cancels its velocity (no bounce).
2585 if let Some((_, vp)) = viewports.iter().find(|(i, _)| *i == id) {
2586 let clamped = moved.clamped(*vp);
2587 if (clamped.tx - moved.tx).abs() > 1e-3 {
2588 v.0 = 0.0;
2589 }
2590 if (clamped.ty - moved.ty).abs() > 1e-3 {
2591 v.1 = 0.0;
2592 }
2593 view = clamped;
2594 } else {
2595 view = moved;
2596 }
2597 self.interactive.insert(id, view);
2598 v.0 *= decay;
2599 v.1 *= decay;
2600 if v.0.hypot(v.1) < PAN_MIN_VELOCITY {
2601 self.interactive_velocity.remove(&id);
2602 } else {
2603 self.interactive_velocity.insert(id, v);
2604 animating = true;
2605 }
2606 }
2607 animating
2608 }
2609
2610 /// Fades out the opacity of outgoing subtrees; forgets those that have reached
2611 /// 0. Returns `true` if an exit is still running.
2612 pub fn advance_leaving(&mut self, dt: f32) -> bool {
2613 let step = if ANIM_DURATION > 0.0 {
2614 dt / ANIM_DURATION
2615 } else {
2616 1.0
2617 };
2618 let mut animating = false;
2619 self.leaving.retain(|_, (_, opacity)| {
2620 *opacity -= step;
2621 if *opacity > 0.0 {
2622 animating = true;
2623 true
2624 } else {
2625 false
2626 }
2627 });
2628 animating
2629 }
2630}
2631
2632#[cfg(test)]
2633mod tests {
2634 use super::*;
2635
2636 /// A vertical scroll region `max` px long, in a 400 px viewport, with no
2637 /// physics of its own.
2638 fn region(id: WidgetId, max: f32) -> Scrollable {
2639 Scrollable {
2640 id,
2641 viewport: Rect::new(0.0, 0.0, 300.0, 400.0),
2642 max_x: 0.0,
2643 max_y: max,
2644 axis: crate::scroll::Axis::Vertical,
2645 physics: crate::ScrollPhysics::CLAMPING,
2646 overscroll: Some(crate::OverscrollIndicator::Glow),
2647 fling: frus_core::VelocityStrategy::Regression,
2648 refresh: None,
2649 page: None,
2650 reverse_x: false,
2651 reverse_y: false,
2652 host: None,
2653 keep_visible: None,
2654 }
2655 }
2656
2657 /// The same region, with these physics settled on it, as its scroll behaviour would.
2658 fn region_with(physics: ScrollPhysics, id: WidgetId, max: f32) -> Scrollable {
2659 Scrollable {
2660 physics,
2661 ..region(id, max)
2662 }
2663 }
2664
2665 /// A three-page horizontal view, 300 px wide, asking for `requested`.
2666 fn paged_region(id: WidgetId, requested: usize) -> Scrollable {
2667 Scrollable {
2668 id,
2669 viewport: Rect::new(0.0, 0.0, 300.0, 400.0),
2670 max_x: 600.0,
2671 max_y: 0.0,
2672 axis: crate::scroll::Axis::Vertical,
2673 physics: crate::ScrollPhysics::CLAMPING,
2674 overscroll: Some(crate::OverscrollIndicator::Glow),
2675 fling: frus_core::VelocityStrategy::Regression,
2676 refresh: None,
2677 page: Some(crate::PageSnap {
2678 snapping: true,
2679 extent: 300.0,
2680 count: 3,
2681 requested,
2682 horizontal: true,
2683 }),
2684 reverse_x: false,
2685 reverse_y: false,
2686 host: None,
2687 keep_visible: None,
2688 }
2689 }
2690
2691 #[test]
2692 fn a_paged_release_springs_to_a_page_and_never_coasts() {
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, (40.0, 0.0));
2697 // A flick far too slow to fling an ordinary list still turns the page.
2698 assert!(rt.fling_scroll(area, ScrollPhysics::CLAMPING, (60.0, 0.0)));
2699 for _ in 0..200 {
2700 rt.advance_scroll(&[area], 1.0 / 60.0);
2701 }
2702 let (x, _) = rt.scroll[&id];
2703 assert!((x - 300.0).abs() < 1.0, "settled at {x}");
2704 }
2705
2706 #[test]
2707 fn a_hard_fling_on_a_paged_view_crosses_one_page() {
2708 let id = WidgetId::ROOT.child(0);
2709 let mut rt = Runtime::default();
2710 let area = paged_region(id, 0);
2711 rt.scroll.insert(id, (10.0, 0.0));
2712 rt.fling_scroll(area, ScrollPhysics::CLAMPING, (7000.0, 0.0));
2713 for _ in 0..300 {
2714 rt.advance_scroll(&[area], 1.0 / 60.0);
2715 }
2716 let (x, _) = rt.scroll[&id];
2717 assert!((x - 300.0).abs() < 1.0, "a fling must not skip pages: {x}");
2718 }
2719
2720 #[test]
2721 fn the_page_asked_for_is_taken_on_the_first_sighting_and_on_a_change() {
2722 let id = WidgetId::ROOT.child(0);
2723 let mut rt = Runtime::default();
2724 // First sighting: the view opens there, with no animation to watch.
2725 rt.sync_pages(&[paged_region(id, 2)]);
2726 assert_eq!(rt.scroll[&id], (600.0, 0.0));
2727 assert!(!rt.scroll_target.contains_key(&id));
2728
2729 // Asked again for the same page while the finger has moved it: left alone,
2730 // or the view could not be swiped at all.
2731 rt.scroll.insert(id, (450.0, 0.0));
2732 rt.sync_pages(&[paged_region(id, 2)]);
2733 assert_eq!(rt.scroll[&id], (450.0, 0.0));
2734
2735 // A new page: a glide, not a jump.
2736 rt.sync_pages(&[paged_region(id, 0)]);
2737 assert_eq!(rt.scroll[&id], (450.0, 0.0));
2738 assert_eq!(rt.scroll_target[&id], (0.0, 0.0));
2739 }
2740
2741 #[test]
2742 fn a_page_change_is_reported_once_and_never_on_arrival() {
2743 let id = WidgetId::ROOT.child(0);
2744 let mut rt = Runtime::default();
2745 let area = paged_region(id, 1);
2746 // The view appearing is not a page change, whatever page it opens on.
2747 assert!(rt.page_changes(&[area]).is_empty());
2748
2749 // Half a page across: the rounding has not tipped yet.
2750 rt.scroll.insert(id, (440.0, 0.0));
2751 assert!(rt.page_changes(&[area]).is_empty());
2752 // Past the middle: reported, and only once.
2753 rt.scroll.insert(id, (460.0, 0.0));
2754 assert_eq!(rt.page_changes(&[area]), vec![(id, 2)]);
2755 rt.scroll.insert(id, (600.0, 0.0));
2756 assert!(rt.page_changes(&[area]).is_empty());
2757 }
2758
2759 #[test]
2760 fn hover_rises_then_falls_and_clears() {
2761 let id = WidgetId::ROOT.child(0);
2762 let mut rt = Runtime::default();
2763 rt.input.hovered = Some(id);
2764
2765 // Hovered: small steps → the progress rises without reaching 1.
2766 assert!(rt.advance(0.03)); // ~0.25, still running
2767 assert!(rt.advance(0.03)); // ~0.5, still running
2768 let p = rt.hover_progress(id);
2769 assert!(p > 0.4 && p < 0.6, "progress = {p}");
2770
2771 // A large step: reaches 1.0 and stays there (no more animation).
2772 rt.advance(1.0);
2773 assert_eq!(rt.hover_progress(id), 1.0);
2774 assert!(!rt.advance(0.03));
2775
2776 // End of hover: falls back (still running), then reaches 0 and the entry disappears.
2777 rt.input.hovered = None;
2778 assert!(rt.advance(0.03));
2779 rt.advance(1.0);
2780 assert_eq!(rt.hover_progress(id), 0.0);
2781 assert!(rt.anims.is_empty());
2782 }
2783
2784 /// **A press rises and falls** (milestone 441).
2785 ///
2786 /// It was a flag and nothing else, so a state layer reached full the instant a finger
2787 /// landed and vanished the instant it left. The reference fades its press highlight
2788 /// in and out instead (`ink_highlight.dart:62`), and this is that fade in the shape
2789 /// hover and focus already had here.
2790 #[test]
2791 fn a_press_rises_then_falls_back() {
2792 let id = WidgetId::ROOT.child(3);
2793 let mut rt = Runtime::default();
2794 rt.input.hovered = Some(id);
2795 rt.input.pressed = Some(id);
2796
2797 // Under the finger: part way there, still running.
2798 assert!(rt.advance(0.05));
2799 let p = rt.press_progress(id);
2800 assert!(p > 0.0 && p < 1.0, "on the way in: {p}");
2801 rt.advance(1.0);
2802 assert_eq!(rt.press_progress(id), 1.0);
2803
2804 // Let go: it comes back down rather than disappearing, which is the same jump
2805 // the other way round.
2806 rt.input.pressed = None;
2807 assert!(rt.advance(0.05));
2808 let p = rt.press_progress(id);
2809 assert!(p > 0.0 && p < 1.0, "on the way back: {p}");
2810 rt.advance(1.0);
2811 assert_eq!(rt.press_progress(id), 0.0);
2812 }
2813
2814 /// And it is the **slower** of the two clocks: the reference gives the press 200 ms
2815 /// against the 50 it gives hover and focus (`ink_well.dart:995`). A press is the one
2816 /// interaction the pointer commits to, and it is drawn slowly enough to be seen.
2817 #[test]
2818 fn a_press_arrives_more_slowly_than_a_hover() {
2819 let id = WidgetId::ROOT.child(4);
2820 let mut rt = Runtime::default();
2821 rt.input.hovered = Some(id);
2822 rt.input.pressed = Some(id);
2823 rt.advance(0.06);
2824 let (press, hover) = (rt.press_progress(id), rt.hover_progress(id));
2825 assert!(press < hover, "press {press} should trail hover {hover}");
2826 assert!(press > 0.0, "but it is under way");
2827 }
2828
2829 /// A press counts only while the pointer is **still on the widget** — the rule
2830 /// `InputState::status_for` applies, so the progression and the flag can never
2831 /// disagree about what is happening.
2832 #[test]
2833 fn a_press_dragged_off_the_widget_comes_back_down() {
2834 let id = WidgetId::ROOT.child(5);
2835 let elsewhere = WidgetId::ROOT.child(6);
2836 let mut rt = Runtime::default();
2837 rt.input.hovered = Some(id);
2838 rt.input.pressed = Some(id);
2839 rt.advance(1.0);
2840 assert_eq!(rt.press_progress(id), 1.0);
2841
2842 // Still held, but the finger has slid away: not a press any more. Long enough
2843 // for the hover to have finished falling and the press not to have — which is
2844 // the case the forget-predicate has to survive, since an entry with nothing left
2845 // *but* a press is exactly what a release looks like on the way out.
2846 rt.input.hovered = Some(elsewhere);
2847 assert!(rt.advance(0.13));
2848 assert_eq!(rt.hover_progress(id), 0.0, "the hover is done");
2849 let p = rt.press_progress(id);
2850 assert!(
2851 p > 0.0 && p < 1.0,
2852 "and the press is still coming down: {p}"
2853 );
2854
2855 rt.advance(1.0);
2856 assert_eq!(rt.press_progress(id), 0.0);
2857 }
2858
2859 #[test]
2860 fn focus_animates_independently() {
2861 let id = WidgetId::ROOT.child(1);
2862 let mut rt = Runtime::default();
2863 rt.input.focused = Some(id);
2864 rt.advance(1.0);
2865 assert_eq!(rt.focus_progress(id), 1.0);
2866 assert_eq!(rt.hover_progress(id), 0.0);
2867 }
2868
2869 #[test]
2870 fn opacity_rises_to_one() {
2871 let id = WidgetId::ROOT.child(2);
2872 let mut rt = Runtime::default();
2873 // Mount: starts transparent.
2874 rt.anims.insert(
2875 id,
2876 Anim {
2877 opacity: 0.0,
2878 ..Default::default()
2879 },
2880 );
2881 assert!(rt.advance(0.03));
2882 let o = rt.opacity(id);
2883 assert!(o > 0.0 && o < 1.0, "opacity = {o}");
2884 rt.advance(1.0);
2885 assert_eq!(rt.opacity(id), 1.0);
2886 // Default without an entry: opaque.
2887 assert_eq!(rt.opacity(WidgetId::ROOT), 1.0);
2888 }
2889
2890 /// **Motion off means the change lands at once, not that it does not land.**
2891 ///
2892 /// The user asked for animations to be reduced or removed, so an implicit animation
2893 /// completes in the frame it starts rather than over its duration. Skipping the
2894 /// change instead would leave the interface *wrong* rather than *still*, which is
2895 /// the failure this setting is most often given.
2896 ///
2897 /// The check is both halves: the value has arrived, **and** nothing is still asking
2898 /// for another frame.
2899 #[test]
2900 fn motion_off_lands_the_change_at_once() {
2901 // A switch mounts at its target, so the animation is what happens on the
2902 // **change** — which is exactly what this setting is about.
2903 let off: crate::Switch<()> = crate::Switch::new(false);
2904 let on: crate::Switch<()> = crate::Switch::new(true);
2905
2906 let mut moving = Runtime::default();
2907 assert!(!moving.advance_values(&off, 1.0));
2908 assert!(
2909 moving.advance_values(&on, 0.03),
2910 "a live animation owes the next frame to itself"
2911 );
2912 let part_way = moving.value(WidgetId::ROOT);
2913 assert!(part_way > 0.0 && part_way < 1.0, "part way: {part_way}");
2914
2915 let mut stopped = Runtime {
2916 still: true,
2917 ..Default::default()
2918 };
2919 assert!(!stopped.advance_values(&off, 1.0));
2920 let more = stopped.advance_values(&on, 0.03);
2921 assert!(!more, "motion off: nothing is left to animate");
2922 assert_eq!(
2923 stopped.value(WidgetId::ROOT),
2924 1.0,
2925 "and the value the widget asked for has actually arrived"
2926 );
2927 }
2928
2929 /// **A value a finger holds stays where it was put** (milestone 638), whatever its
2930 /// target; let go, it travels straight to the target in the share of the duration the
2931 /// distance left is, and is no longer *placed* once it arrives.
2932 #[test]
2933 fn a_held_value_stays_and_settles_from_where_it_was_left() {
2934 let off: crate::Switch<()> = crate::Switch::new(false);
2935 let mut rt = Runtime::default();
2936 rt.advance_values(&off, 1.0);
2937 rt.hold_value(WidgetId::ROOT, 0.25);
2938 assert!(rt.value_placed(WidgetId::ROOT));
2939 assert!(!rt.advance_values(&off, 1.0), "held: nothing moves");
2940 assert_eq!(rt.value(WidgetId::ROOT), 0.25);
2941
2942 rt.release_value(WidgetId::ROOT);
2943 // A quarter of the way to go is a quarter of the 300 ms: half of it is 37.5 ms.
2944 assert!(rt.advance_values(&off, 0.0375));
2945 let half = rt.value(WidgetId::ROOT);
2946 assert!((half - 0.125).abs() < 1e-4, "linear, half way: {half}");
2947 assert!(rt.value_placed(WidgetId::ROOT), "still settling");
2948 rt.advance_values(&off, 0.0375);
2949 assert_eq!(rt.value(WidgetId::ROOT), 0.0);
2950 rt.advance_values(&off, 0.0);
2951 assert!(
2952 !rt.value_placed(WidgetId::ROOT),
2953 "arrived: no longer placed"
2954 );
2955 }
2956
2957 #[test]
2958 fn value_snaps_on_mount_then_animates() {
2959 let mut rt = Runtime::default();
2960 // Mounting a switch that is off: adopts the target (0) with no animation.
2961 let off: crate::Switch<()> = crate::Switch::new(false);
2962 assert!(!rt.advance_values(&off, 1.0));
2963 assert_eq!(rt.value(WidgetId::ROOT), 0.0);
2964
2965 // Toggled on: the value rises towards 1 in small steps.
2966 let on: crate::Switch<()> = crate::Switch::new(true);
2967 assert!(rt.advance_values(&on, 0.03));
2968 let v = rt.value(WidgetId::ROOT);
2969 assert!(v > 0.0 && v < 1.0, "value = {v}");
2970 rt.advance_values(&on, 1.0);
2971 assert_eq!(rt.value(WidgetId::ROOT), 1.0);
2972
2973 // Widget gone: the value is forgotten.
2974 let empty: crate::Container<()> = crate::Container::new();
2975 rt.advance_values(&empty, 1.0);
2976 assert!(rt.values.is_empty());
2977 }
2978
2979 /// Minimal widget exposing a tunable animated value (target, duration, curve) —
2980 /// to test the timeline without depending on a concrete widget.
2981 struct Mock {
2982 target: f32,
2983 duration: f32,
2984 curve: Curve,
2985 }
2986
2987 impl crate::widget::Widget<()> for Mock {
2988 fn style(&self) -> frus_layout::Style {
2989 frus_layout::Style::default()
2990 }
2991 fn children(&self) -> &[Box<dyn crate::widget::Widget<()>>] {
2992 &[]
2993 }
2994 fn paint(
2995 &self,
2996 _bounds: frus_core::Rect,
2997 _status: crate::interaction::Status,
2998 _theme: &crate::theme::Theme,
2999 _scene: &mut frus_core::Scene,
3000 ) {
3001 }
3002 fn on_click(&self) -> Option<()> {
3003 None
3004 }
3005 fn anim_target(&self) -> Option<f32> {
3006 Some(self.target)
3007 }
3008 fn anim_duration(&self) -> f32 {
3009 self.duration
3010 }
3011 fn anim_curve(&self) -> Curve {
3012 self.curve.clone()
3013 }
3014 }
3015
3016 /// The **curve** shapes the trajectory: at t=0.25 an *ease-in* lags behind the
3017 /// linear progress and an *ease-out* runs ahead; all of them converge on the
3018 /// target.
3019 #[test]
3020 fn curve_shapes_the_value_timeline() {
3021 let id = WidgetId::ROOT;
3022 let dt = 0.03; // t = 0.25 over a duration of 0.12
3023 let dur = 0.12;
3024 let sample = |curve: Curve| {
3025 let mut rt = Runtime::default();
3026 rt.set_value(id, 0.0);
3027 rt.advance_values(
3028 &Mock {
3029 target: 1.0,
3030 duration: dur,
3031 curve,
3032 },
3033 dt,
3034 );
3035 (rt.value(id), rt)
3036 };
3037 let (ein, mut rt_in) = sample(Curve::ease_in());
3038 let (eout, mut rt_out) = sample(Curve::ease_out());
3039 let (lin, mut rt_lin) = sample(Curve::Linear);
3040
3041 assert!((lin - 0.25).abs() < 1e-3, "linear = t: {lin}");
3042 assert!(ein < 0.25, "ease-in lags: {ein}");
3043 assert!(eout > 0.25, "ease-out leads: {eout}");
3044
3045 // A large step: all of them reach the target (the curves end at 1).
3046 for rt in [&mut rt_in, &mut rt_out, &mut rt_lin] {
3047 rt.advance_values(
3048 &Mock {
3049 target: 1.0,
3050 duration: dur,
3051 curve: Curve::Linear,
3052 },
3053 1.0,
3054 );
3055 }
3056 assert_eq!(rt_in.value(id), 1.0);
3057 assert_eq!(rt_out.value(id), 1.0);
3058 assert_eq!(rt_lin.value(id), 1.0);
3059 }
3060
3061 /// The animated colour **snaps** on mount then **tweens** on a change of target,
3062 /// channel by channel; a widget that has gone is forgotten.
3063 #[test]
3064 fn animated_color_tweens_between_frames() {
3065 let id = WidgetId::ROOT;
3066 let red = Color::rgb(1.0, 0.0, 0.0);
3067 let blue = Color::rgb(0.0, 0.0, 1.0);
3068 let mut rt = Runtime::default();
3069
3070 // Mounting on red: adopts the target with no transition.
3071 let start: crate::Container<()> =
3072 crate::Container::new().animated_color(red, 0.10, Curve::Linear);
3073 assert!(!rt.advance_colors(&start, 1.0));
3074 assert_eq!(rt.anim_color(id), Some(red));
3075
3076 // Blue target: linear tween, halfway ≈ (0.5, 0, 0.5).
3077 let to_blue: crate::Container<()> =
3078 crate::Container::new().animated_color(blue, 0.10, Curve::Linear);
3079 assert!(rt.advance_colors(&to_blue, 0.05));
3080 let mid = rt.anim_color(id).unwrap();
3081 assert!(
3082 (mid.r - 0.5).abs() < 0.05 && (mid.b - 0.5).abs() < 0.05,
3083 "mi-parcours = {mid:?}"
3084 );
3085
3086 // The end: blue reached.
3087 rt.advance_colors(&to_blue, 1.0);
3088 assert_eq!(rt.anim_color(id), Some(blue));
3089
3090 // Widget gone: the colour is forgotten.
3091 let empty: crate::Container<()> = crate::Container::new();
3092 rt.advance_colors(&empty, 1.0);
3093 assert_eq!(rt.anim_color(id), None);
3094 }
3095
3096 /// The animated size **snaps** on mount then **tweens** on a change of target
3097 /// (width/height); a widget that has gone is forgotten.
3098 #[test]
3099 fn animated_size_tweens_between_frames() {
3100 let id = WidgetId::ROOT;
3101 let mut rt = Runtime::default();
3102
3103 let small: crate::Container<()> =
3104 crate::Container::new().animated_size(20.0, 20.0, 0.10, Curve::Linear);
3105 assert!(!rt.advance_sizes(&small, 1.0));
3106 assert_eq!(rt.anim_size(id), Some(Size::new(20.0, 20.0)));
3107
3108 // 40×40 target: halfway through a linear tween ≈ 30×30.
3109 let big: crate::Container<()> =
3110 crate::Container::new().animated_size(40.0, 40.0, 0.10, Curve::Linear);
3111 assert!(rt.advance_sizes(&big, 0.05));
3112 let mid = rt.anim_size(id).unwrap();
3113 assert!(
3114 (mid.width - 30.0).abs() < 0.5 && (mid.height - 30.0).abs() < 0.5,
3115 "mi-parcours = {mid:?}"
3116 );
3117
3118 rt.advance_sizes(&big, 1.0);
3119 assert_eq!(rt.anim_size(id), Some(Size::new(40.0, 40.0)));
3120
3121 let empty: crate::Container<()> = crate::Container::new();
3122 rt.advance_sizes(&empty, 1.0);
3123 assert_eq!(rt.anim_size(id), None);
3124 }
3125
3126 /// The animated corner radius **snaps** on mount then **tweens** on a change of
3127 /// target (per corner); a widget that has gone is forgotten.
3128 #[test]
3129 fn animated_radius_tweens_between_frames() {
3130 let id = WidgetId::ROOT;
3131 let mut rt = Runtime::default();
3132
3133 let sharp: crate::Container<()> =
3134 crate::Container::new().animated_radius(0.0, 0.10, Curve::Linear);
3135 assert!(!rt.advance_radii(&sharp, 1.0));
3136 assert_eq!(rt.anim_radius(id), Some(BorderRadius::from(0.0)));
3137
3138 // Target 20: halfway through a linear tween ≈ 10.
3139 let round: crate::Container<()> =
3140 crate::Container::new().animated_radius(20.0, 0.10, Curve::Linear);
3141 assert!(rt.advance_radii(&round, 0.05));
3142 let mid = rt.anim_radius(id).unwrap();
3143 assert!(
3144 (mid.top_left - 10.0).abs() < 0.5,
3145 "mi-parcours = {}",
3146 mid.top_left
3147 );
3148
3149 rt.advance_radii(&round, 1.0);
3150 assert_eq!(rt.anim_radius(id), Some(BorderRadius::from(20.0)));
3151
3152 let empty: crate::Container<()> = crate::Container::new();
3153 rt.advance_radii(&empty, 1.0);
3154 assert_eq!(rt.anim_radius(id), None);
3155 }
3156
3157 /// The animated padding **snaps** on mount then **tweens** on a change of target
3158 /// (per side); a widget that has gone is forgotten.
3159 #[test]
3160 fn animated_padding_tweens_between_frames() {
3161 let id = WidgetId::ROOT;
3162 let mut rt = Runtime::default();
3163
3164 let p0: crate::Container<()> =
3165 crate::Container::new().animated_padding(0.0, 0.10, Curve::Linear);
3166 assert!(!rt.advance_paddings(&p0, 1.0));
3167 assert_eq!(rt.anim_padding(id), Some(Insets::uniform(0.0)));
3168
3169 let p20: crate::Container<()> =
3170 crate::Container::new().animated_padding(20.0, 0.10, Curve::Linear);
3171 assert!(rt.advance_paddings(&p20, 0.05)); // t = 0.5 → 10
3172 let mid = rt.anim_padding(id).unwrap();
3173 assert!((mid.left - 10.0).abs() < 0.5, "mi-parcours = {}", mid.left);
3174
3175 rt.advance_paddings(&p20, 1.0);
3176 assert_eq!(rt.anim_padding(id), Some(Insets::uniform(20.0)));
3177
3178 let empty: crate::Container<()> = crate::Container::new();
3179 rt.advance_paddings(&empty, 1.0);
3180 assert_eq!(rt.anim_padding(id), None);
3181 }
3182
3183 /// The **duration** sets the speed: for the same `dt`, a shorter transition is
3184 /// further along.
3185 #[test]
3186 fn shorter_duration_animates_faster() {
3187 let id = WidgetId::ROOT;
3188 let advance = |duration: f32| {
3189 let mut rt = Runtime::default();
3190 rt.set_value(id, 0.0);
3191 rt.advance_values(
3192 &Mock {
3193 target: 1.0,
3194 duration,
3195 curve: Curve::Linear,
3196 },
3197 0.025,
3198 );
3199 rt.value(id)
3200 };
3201 let fast = advance(0.05); // t = 0.5
3202 let slow = advance(0.20); // t = 0.125
3203 assert!(
3204 fast > slow,
3205 "shorter duration further along: {fast} vs {slow}"
3206 );
3207 assert!((fast - 0.5).abs() < 1e-3, "fast = {fast}");
3208 assert!((slow - 0.125).abs() < 1e-3, "slow = {slow}");
3209 }
3210
3211 #[test]
3212 fn spring_ease_is_monotonic_no_overshoot() {
3213 assert!((spring_ease(0.0) - 0.0).abs() < 1e-6);
3214 assert!((spring_ease(1.0) - 1.0).abs() < 1e-6);
3215 // Increasing and bounded to [0,1] (no overshoot beyond 1).
3216 let mut prev = 0.0;
3217 for i in 0..=100 {
3218 let v = spring_ease(i as f32 / 100.0);
3219 assert!(v >= prev - 1e-6, "decreases at {i}");
3220 assert!(v <= 1.0 + 1e-6, "exceeds 1 at {i}");
3221 prev = v;
3222 }
3223 // Already well advanced at halfway (a gentle arrival at the end).
3224 assert!(spring_ease(0.5) > 0.7);
3225 // Clamped outside the domain.
3226 assert_eq!(spring_ease(-1.0), 0.0);
3227 assert_eq!(spring_ease(2.0), 1.0);
3228 }
3229
3230 #[test]
3231 fn scroll_springs_to_target_and_settles() {
3232 let id = WidgetId::ROOT;
3233 let mut rt = Runtime::default();
3234 rt.scroll_target.insert(id, (0.0, 100.0));
3235 rt.scroll_velocity.insert(id, (0.0, 0.0));
3236 let regions = [region(id, 200.0)];
3237 for _ in 0..600 {
3238 if !rt.advance_scroll(®ions, 0.016) {
3239 break;
3240 }
3241 }
3242 let (_, y) = rt.scroll.get(&id).copied().unwrap();
3243 assert!((y - 100.0).abs() < 1.0, "reached the target: {y}");
3244 assert!(
3245 !rt.scroll_target.contains_key(&id),
3246 "animation state cleared at rest"
3247 );
3248 }
3249
3250 /// **A scrollbar arrives when its area moves, and leaves once it has been still**
3251 /// (`scrollbar.dart:17`, `:18`): 600 ms of stillness, then 300 ms of going.
3252 ///
3253 /// Nothing announces that movement. The offset is written by the drag handler, by
3254 /// the fling and by the spring, and none of the three leaves word — so this watches
3255 /// the value itself, which catches all three and whatever is added next.
3256 #[test]
3257 fn a_bar_arrives_on_movement_and_goes_again() {
3258 let id = WidgetId::ROOT;
3259 let mut rt = Runtime::default();
3260 let regions = [region(id, 200.0)];
3261 let run = |rt: &mut Runtime, frames: usize| {
3262 for _ in 0..frames {
3263 rt.advance_scroll(®ions, 0.016);
3264 }
3265 };
3266
3267 // An area nobody has scrolled has no bar at all.
3268 run(&mut rt, 10);
3269 assert_eq!(
3270 rt.scrollbar_fade_of(id).opacity,
3271 0.0,
3272 "untouched: no bar to see"
3273 );
3274
3275 // Moved by something — anything — and it comes, over `BAR_FADE`.
3276 rt.scroll.insert(id, (0.0, 40.0));
3277 run(&mut rt, 1);
3278 let arriving = rt.scrollbar_fade_of(id).opacity;
3279 assert!(
3280 arriving > 0.0 && arriving < 1.0,
3281 "on its way in, not snapped: {arriving}"
3282 );
3283 run(&mut rt, 20);
3284 assert!(rt.scrollbar_fade_of(id).opacity > 0.99, "there");
3285
3286 // Still for less than `BAR_TIME_TO_FADE` all told: it stays put.
3287 run(&mut rt, 10);
3288 assert!(
3289 rt.scrollbar_fade_of(id).opacity > 0.99,
3290 "half a second of stillness is not enough to send it away"
3291 );
3292
3293 // And then it goes.
3294 run(&mut rt, 80);
3295 assert_eq!(rt.scrollbar_fade_of(id).opacity, 0.0, "gone");
3296 }
3297
3298 /// **A held thumb keeps its bar**, however long the hand pauses on it
3299 /// (`scrollbar.dart:1970`), and so does a bar a pointer is resting near — which is
3300 /// what lets a faded bar be found again at all (`scrollbar.dart:2132`).
3301 #[test]
3302 fn a_bar_stays_for_the_pointer_that_wants_it() {
3303 let id = WidgetId::ROOT;
3304 let regions = [region(id, 200.0)];
3305 let run = |rt: &mut Runtime, frames: usize| {
3306 for _ in 0..frames {
3307 rt.advance_scroll(®ions, 0.016);
3308 }
3309 };
3310
3311 let mut held = Runtime::default();
3312 held.scroll.insert(id, (0.0, 40.0));
3313 held.scrollbar_dragged = Some(id);
3314 run(&mut held, 140); // well past 600 ms of stillness
3315 assert!(
3316 held.scrollbar_fade_of(id).opacity > 0.99,
3317 "a thumb in a hand does not fade out from under it"
3318 );
3319
3320 // A bar that has gone entirely comes back for a pointer that reaches for it,
3321 // and the thumb warms towards its hovered colour while it is there.
3322 let mut faded = Runtime::default();
3323 run(&mut faded, 10);
3324 assert_eq!(faded.scrollbar_fade_of(id).opacity, 0.0);
3325 faded.scrollbar_hovered = Some(id);
3326 run(&mut faded, 25);
3327 assert!(faded.scrollbar_fade_of(id).opacity > 0.99, "back");
3328 assert!(faded.scrollbar_fade_of(id).hover > 0.99, "and warmed");
3329 }
3330
3331 #[test]
3332 fn interactive_fling_decelerates_settles_and_stays_bounded() {
3333 use crate::interactive::InteractiveView;
3334 let id = WidgetId::ROOT;
3335 let vp = Rect::new(0.0, 0.0, 200.0, 200.0);
3336 let mut rt = Runtime::default();
3337 // Content zoomed ×2, flung leftwards at high speed.
3338 rt.interactive.insert(
3339 id,
3340 InteractiveView {
3341 scale: 2.0,
3342 tx: 0.0,
3343 ty: 0.0,
3344 },
3345 );
3346 rt.interactive_velocity.insert(id, (-2000.0, 0.0));
3347 let viewports = [(id, vp)];
3348 let mut frames = 0;
3349 while rt.advance_interactive(&viewports, 0.016) {
3350 frames += 1;
3351 assert!(frames < 1000, "the fling must eventually stop");
3352 }
3353 let view = rt.interactive.get(&id).copied().unwrap();
3354 // Stays clamped (content ×2 covers the viewport → tx ∈ [-200, 0]).
3355 assert!(
3356 view.tx >= -200.0 - 1e-3 && view.tx <= 0.0 + 1e-3,
3357 "clamped: {}",
3358 view.tx
3359 );
3360 assert!(
3361 !rt.interactive_velocity.contains_key(&id),
3362 "velocity cleared at rest"
3363 );
3364 }
3365
3366 #[test]
3367 fn scroll_overshoot_rubber_bands_back_to_max() {
3368 let id = WidgetId::ROOT;
3369 let mut rt = Runtime::default();
3370 // Target beyond the bound (overshoot) → must come back to max.
3371 rt.scroll_target.insert(id, (0.0, 240.0));
3372 rt.scroll_velocity.insert(id, (0.0, 0.0));
3373 let regions = [region(id, 200.0)];
3374 for _ in 0..1000 {
3375 if !rt.advance_scroll(®ions, 0.016) {
3376 break;
3377 }
3378 }
3379 let (_, y) = rt.scroll.get(&id).copied().unwrap();
3380 assert!((y - 200.0).abs() < 1.0, "back at the max bound: {y}");
3381 }
3382
3383 /// Runs a fling to completion and answers where the content came to rest.
3384 fn settle(physics: ScrollPhysics, max: f32, from: f32, velocity: f32) -> f32 {
3385 let id = WidgetId::ROOT;
3386 let mut rt = Runtime::default();
3387 let area = region_with(physics, id, max);
3388 rt.scroll.insert(id, (0.0, from));
3389 rt.fling_scroll(area, physics, (0.0, velocity));
3390 for _ in 0..1200 {
3391 if !rt.advance_scroll(&[area], 1.0 / 60.0) {
3392 break;
3393 }
3394 }
3395 assert!(
3396 rt.scroll_ballistic.is_empty(),
3397 "the fling should have finished"
3398 );
3399 rt.scroll.get(&id).copied().unwrap().1
3400 }
3401
3402 #[test]
3403 fn a_clamping_fling_stops_at_the_edge() {
3404 // Far more momentum than there is content: it must stop exactly at the end,
3405 // with no overshoot to spring back from.
3406 let rest = settle(ScrollPhysics::CLAMPING, 400.0, 0.0, 6000.0);
3407 assert_eq!(rest, 400.0, "stopped dead at the end, at {rest}");
3408 // And symmetrically at the start.
3409 let rest = settle(ScrollPhysics::CLAMPING, 400.0, 400.0, -6000.0);
3410 assert_eq!(rest, 0.0, "stopped dead at the start, at {rest}");
3411 }
3412
3413 #[test]
3414 fn a_clamping_fling_that_fits_lands_where_the_platform_says() {
3415 // 1000 px/s covers about 194 px under the platform's spline.
3416 let rest = settle(ScrollPhysics::CLAMPING, 4000.0, 0.0, 1000.0);
3417 assert!((rest - 194.0).abs() < 3.0, "landed at {rest}");
3418 }
3419
3420 #[test]
3421 fn a_bouncing_fling_overshoots_then_settles_back_on_the_edge() {
3422 let id = WidgetId::ROOT;
3423 let physics = ScrollPhysics::BOUNCING;
3424 let mut rt = Runtime::default();
3425 let area = region_with(physics, id, 400.0);
3426 rt.scroll.insert(id, (0.0, 300.0));
3427 rt.fling_scroll(area, physics, (0.0, 4000.0));
3428 let mut peak: f32 = 0.0;
3429 for _ in 0..1200 {
3430 let moving = rt.advance_scroll(&[area], 1.0 / 60.0);
3431 peak = peak.max(rt.scroll.get(&id).copied().unwrap().1);
3432 if !moving {
3433 break;
3434 }
3435 }
3436 assert!(
3437 peak > 400.0,
3438 "it should have gone past the end, peak {peak}"
3439 );
3440 let rest = rt.scroll.get(&id).copied().unwrap().1;
3441 assert!(
3442 (rest - 400.0).abs() < 1.0,
3443 "came back to the end, at {rest}"
3444 );
3445 }
3446
3447 #[test]
3448 fn an_overscrolled_offset_comes_home_even_without_a_fling() {
3449 // A release too slow to fling still owes the content its edge back.
3450 for physics in [ScrollPhysics::BOUNCING, ScrollPhysics::CLAMPING] {
3451 let rest = settle(physics, 400.0, 460.0, 5.0);
3452 assert!(
3453 (rest - 400.0).abs() < 1.0,
3454 "{physics:?} left the content at {rest}"
3455 );
3456 }
3457 }
3458
3459 #[test]
3460 fn a_release_with_nothing_to_do_starts_no_fling() {
3461 let id = WidgetId::ROOT;
3462 let mut rt = Runtime::default();
3463 rt.scroll.insert(id, (0.0, 100.0));
3464 assert!(!rt.fling_scroll(region(id, 400.0), ScrollPhysics::CLAMPING, (0.0, 5.0)));
3465 assert!(rt.scroll_ballistic.is_empty());
3466 }
3467
3468 #[test]
3469 fn a_held_region_is_not_dragged_back_by_the_spring() {
3470 // The bug the device found: while a finger holds an overscrolled offset,
3471 // the edge spring kept retracting it between two moves, so a rubber band
3472 // was pulled back as fast as it was stretched and never appeared.
3473 let id = WidgetId::ROOT;
3474 let physics = ScrollPhysics::BOUNCING;
3475 let area = region_with(physics, id, 400.0);
3476 let pulled = -60.0;
3477
3478 let mut held = Runtime::default();
3479 held.hold_scroll(id);
3480 held.scroll.insert(id, (0.0, pulled));
3481 held.scroll_target.insert(id, (0.0, pulled));
3482 for _ in 0..10 {
3483 held.advance_scroll(&[area], 1.0 / 60.0);
3484 }
3485 assert_eq!(
3486 held.scroll.get(&id).copied().unwrap().1,
3487 pulled,
3488 "a held offset must not move on its own"
3489 );
3490
3491 // The same offset, let go: now it must come home.
3492 let mut free = Runtime::default();
3493 free.scroll.insert(id, (0.0, pulled));
3494 free.scroll_target.insert(id, (0.0, pulled));
3495 while free.advance_scroll(&[area], 1.0 / 60.0) {}
3496 assert!(
3497 free.scroll.get(&id).copied().unwrap().1.abs() < 1.0,
3498 "a released overscroll springs back"
3499 );
3500 }
3501
3502 /// Milestone 533. A fling owns its offset as a finger does: content that shrinks under
3503 /// a bounce does not snap it to the new end in one frame, and when the bounce settles
3504 /// on the end it was aimed at — the old one, now past the content — it springs on to the
3505 /// new end rather than jumping there.
3506 #[test]
3507 fn a_fling_the_content_shrank_under_springs_on_to_the_new_end() {
3508 let id = WidgetId::ROOT;
3509 let physics = ScrollPhysics::BOUNCING;
3510 let mut rt = Runtime::default();
3511 rt.scroll.insert(id, (0.0, 300.0));
3512 assert!(rt.fling_scroll(region_with(physics, id, 400.0), physics, (0.0, 3000.0)));
3513
3514 let shrunk = [region_with(physics, id, 100.0)];
3515 let mut previous = 300.0;
3516 let mut biggest_step: f32 = 0.0;
3517 let mut frames = 0;
3518 while rt.advance_scroll(&shrunk, 1.0 / 60.0) {
3519 let y = rt.scroll.get(&id).copied().unwrap().1;
3520 biggest_step = biggest_step.max((y - previous).abs());
3521 previous = y;
3522 frames += 1;
3523 assert!(frames < 2000, "the motion must end");
3524 }
3525 let y = rt.scroll.get(&id).copied().unwrap().1;
3526 biggest_step = biggest_step.max((y - previous).abs());
3527 assert!((y - 100.0).abs() < 1.0, "home at the new end, at {y}");
3528 assert!(
3529 biggest_step < 120.0,
3530 "carried there, not jumped: {biggest_step} px in one frame"
3531 );
3532 assert!(rt.scroll_ballistic.is_empty() && rt.scroll_target.is_empty());
3533 }
3534
3535 /// Milestone 533. A glide still under way — a wheel's, or a slow release's — is left to
3536 /// its own spring, which already pulls a target past the end back inside it.
3537 #[test]
3538 fn a_glide_the_content_shrank_under_is_not_snapped() {
3539 let id = WidgetId::ROOT;
3540 let mut rt = Runtime::default();
3541 rt.scroll.insert(id, (0.0, 400.0));
3542 rt.scroll_target.insert(id, (0.0, 400.0));
3543 let shrunk = [region(id, 100.0)];
3544 rt.advance_scroll(&shrunk, 1.0 / 60.0);
3545 let first = rt.scroll.get(&id).copied().unwrap().1;
3546 assert!(first > 300.0, "one frame in, still gliding: {first}");
3547 while rt.advance_scroll(&shrunk, 1.0 / 60.0) {}
3548 let rest = rt.scroll.get(&id).copied().unwrap().1;
3549 assert!((rest - 100.0).abs() < 1.0, "and it glides home: {rest}");
3550 }
3551
3552 /// Milestone 533. Nothing owns the offset, the content shrank: it is corrected at once,
3553 /// both axes, both ends of the range.
3554 #[test]
3555 fn an_offset_nobody_owns_is_kept_inside_the_content() {
3556 let id = WidgetId::ROOT;
3557 let mut rt = Runtime::default();
3558 let area = Scrollable {
3559 max_x: 30.0,
3560 ..region(id, 100.0)
3561 };
3562 rt.scroll.insert(id, (80.0, 400.0));
3563 assert!(rt.keep_scroll_in_range(&[area]), "something was corrected");
3564 assert_eq!(rt.scroll.get(&id), Some(&(30.0, 100.0)));
3565 assert!(
3566 !rt.keep_scroll_in_range(&[area]),
3567 "and nothing is left to correct"
3568 );
3569 rt.scroll.insert(id, (-5.0, -20.0));
3570 rt.keep_scroll_in_range(&[area]);
3571 assert_eq!(rt.scroll.get(&id), Some(&(0.0, 0.0)));
3572 // A region nobody has scrolled gains no entry.
3573 let other = region(id.child(1), 0.0);
3574 assert!(!rt.keep_scroll_in_range(&[other]));
3575 assert!(!rt.scroll.contains_key(&other.id));
3576 }
3577
3578 #[test]
3579 fn releasing_a_region_that_was_never_held_changes_nothing() {
3580 let mut rt = Runtime::default();
3581 rt.hold_scroll(WidgetId::ROOT);
3582 rt.release_scroll(WidgetId::ROOT.child(1));
3583 assert_eq!(
3584 rt.scroll_held,
3585 Some(WidgetId::ROOT),
3586 "another region's release must not steal the hold"
3587 );
3588 rt.release_scroll(WidgetId::ROOT);
3589 assert_eq!(rt.scroll_held, None);
3590 }
3591
3592 #[test]
3593 fn a_clamping_fling_lights_the_edge_it_slams_into() {
3594 let id = WidgetId::ROOT;
3595 let physics = ScrollPhysics::CLAMPING;
3596 let mut rt = Runtime::default();
3597 let area = region_with(physics, id, 400.0);
3598 // Far more momentum than there is content: it will reach the end.
3599 rt.fling_scroll(area, physics, (0.0, 6000.0));
3600 while rt.advance_scroll(&[area], 1.0 / 60.0) {}
3601 let glows = rt
3602 .scroll_glow
3603 .get(&id)
3604 .expect("the edge should have lit up");
3605 assert!(!glows.edge(GlowEdge::Bottom).is_idle(), "the end glows");
3606 assert!(
3607 glows.edge(GlowEdge::Top).is_idle(),
3608 "and only the end — the fling never touched the start"
3609 );
3610 // It is a flash, not a permanent mark.
3611 while rt.advance_glow(1.0 / 60.0) {}
3612 assert!(rt.scroll_glow.is_empty(), "the glow is dropped once quiet");
3613 }
3614
3615 #[test]
3616 fn a_bouncing_fling_lights_nothing() {
3617 // The bounce *is* the feedback: a glow on top of it would say the same
3618 // thing twice.
3619 let id = WidgetId::ROOT;
3620 let physics = ScrollPhysics::BOUNCING;
3621 let mut rt = Runtime::default();
3622 let area = region_with(physics, id, 400.0);
3623 rt.fling_scroll(area, physics, (0.0, 6000.0));
3624 while rt.advance_scroll(&[area], 1.0 / 60.0) {}
3625 assert!(rt.scroll_glow.is_empty());
3626 }
3627
3628 #[test]
3629 fn a_pull_that_was_refused_lights_the_edge_and_fades() {
3630 let id = WidgetId::ROOT;
3631 let mut rt = Runtime::default();
3632 rt.glow_pull(
3633 id,
3634 Some(crate::physics::OverscrollIndicator::Glow),
3635 GlowEdge::Top,
3636 -30.0,
3637 600.0,
3638 150.0,
3639 300.0,
3640 );
3641 assert!(!rt.scroll_glow.get(&id).unwrap().is_idle());
3642 rt.glow_scroll_end(id);
3643 let mut frames = 0;
3644 while rt.advance_glow(1.0 / 60.0) {
3645 frames += 1;
3646 assert!(frames < 200, "the glow never faded");
3647 }
3648 assert!(rt.scroll_glow.is_empty());
3649 }
3650
3651 #[test]
3652 fn a_refusal_of_nothing_lights_nothing() {
3653 let mut rt = Runtime::default();
3654 rt.glow_pull(
3655 WidgetId::ROOT,
3656 Some(crate::physics::OverscrollIndicator::Glow),
3657 GlowEdge::Top,
3658 0.0,
3659 600.0,
3660 150.0,
3661 300.0,
3662 );
3663 assert!(
3664 rt.scroll_glow.is_empty(),
3665 "a zero pull is not an overscroll"
3666 );
3667 assert!(!rt.advance_glow(1.0 / 60.0));
3668 }
3669
3670 /// **An area whose behaviour shows nothing past an edge shows nothing** (milestone
3671 /// 616): neither a pull nor a fling landing on the edge lights or stretches anything,
3672 /// as on iOS and the desktops.
3673 #[test]
3674 fn an_area_with_no_indicator_shows_nothing_past_its_edge() {
3675 let mut rt = Runtime::default();
3676 rt.glow_pull(
3677 WidgetId::ROOT,
3678 None,
3679 GlowEdge::Top,
3680 80.0,
3681 600.0,
3682 150.0,
3683 300.0,
3684 );
3685 rt.glow_absorb(WidgetId::ROOT, None, GlowEdge::Bottom, 4000.0);
3686 assert!(rt.scroll_glow.is_empty() && rt.scroll_stretch.is_empty());
3687 // And a fling that slams into the edge of such an area leaves nothing either.
3688 let id = WidgetId::ROOT;
3689 let quiet = Scrollable {
3690 overscroll: None,
3691 ..region(id, 400.0)
3692 };
3693 rt.fling_scroll(quiet, ScrollPhysics::CLAMPING, (0.0, 6000.0));
3694 while rt.advance_scroll(&[quiet], 1.0 / 60.0) {}
3695 assert!(rt.scroll_glow.is_empty() && rt.scroll_stretch.is_empty());
3696 // Where the area glows, the same fling does light the edge: the instrument works.
3697 let lit = region(id, 400.0);
3698 rt.scroll.clear();
3699 rt.fling_scroll(lit, ScrollPhysics::CLAMPING, (0.0, 6000.0));
3700 while rt.advance_scroll(&[lit], 1.0 / 60.0) {}
3701 assert!(!rt.scroll_glow.is_empty());
3702 }
3703
3704 #[test]
3705 fn a_fling_on_a_region_that_vanished_is_dropped() {
3706 let id = WidgetId::ROOT;
3707 let mut rt = Runtime::default();
3708 rt.fling_scroll(region(id, 400.0), ScrollPhysics::CLAMPING, (0.0, 2000.0));
3709 assert!(!rt.scroll_ballistic.is_empty());
3710 // The route changed: the scrollable is no longer part of the frame.
3711 assert!(!rt.advance_scroll(&[], 1.0 / 60.0));
3712 assert!(rt.scroll_ballistic.is_empty());
3713 }
3714
3715 #[test]
3716 fn leaving_fades_out_and_clears() {
3717 let mut rt = Runtime::default();
3718 rt.leaving.insert(0, (Vec::new(), 1.0));
3719 assert!(rt.advance_leaving(0.06)); // ~0.5, still running
3720 assert!(!rt.advance_leaving(0.06)); // reaches 0 → cleared
3721 assert!(rt.leaving.is_empty());
3722 }
3723
3724 /// **A widget that plays from a start sets off from it when it appears** (milestone 691):
3725 /// `anim_initial` is where the value is on the first frame, and that frame counts.
3726 /// Without it, a widget appears at its target, as a switch does.
3727 #[test]
3728 fn a_value_with_a_start_plays_from_it_on_mount() {
3729 struct Playing(Option<f32>);
3730 impl crate::widget::Widget<()> for Playing {
3731 fn style(&self) -> frus_layout::Style {
3732 frus_layout::Style::default()
3733 }
3734 fn children(&self) -> &[Box<dyn crate::widget::Widget<()>>] {
3735 &[]
3736 }
3737 fn paint(
3738 &self,
3739 _: frus_core::Rect,
3740 _: crate::interaction::Status,
3741 _: &crate::theme::Theme,
3742 _: &mut frus_core::Scene,
3743 ) {
3744 }
3745 fn on_click(&self) -> Option<()> {
3746 None
3747 }
3748 fn anim_target(&self) -> Option<f32> {
3749 Some(1.0)
3750 }
3751 fn anim_initial(&self) -> Option<f32> {
3752 self.0
3753 }
3754 fn anim_duration(&self) -> f32 {
3755 2.0
3756 }
3757 }
3758 let mut rt = Runtime::default();
3759 assert!(rt.advance_values(&Playing(Some(0.0)), 0.5), "still playing");
3760 assert!(
3761 (rt.value(WidgetId::ROOT) - 0.25).abs() < 1e-6,
3762 "a quarter in"
3763 );
3764 assert!(rt.advance_values(&Playing(Some(0.0)), 0.5));
3765 assert!((rt.value(WidgetId::ROOT) - 0.5).abs() < 1e-6, "half way");
3766
3767 let mut plain = Runtime::default();
3768 assert!(!plain.advance_values(&Playing(None), 0.5));
3769 assert_eq!(
3770 plain.value(WidgetId::ROOT),
3771 1.0,
3772 "no start: at the target at once"
3773 );
3774
3775 let mut still = Runtime {
3776 still: true,
3777 ..Default::default()
3778 };
3779 assert!(!still.advance_values(&Playing(Some(0.0)), 0.5));
3780 assert_eq!(
3781 still.value(WidgetId::ROOT),
3782 1.0,
3783 "motion off: at the end at once"
3784 );
3785 }
3786}