frust-widgets 0.5.1

Baseline widget set for Frust: text, buttons, images, flex, stack and scroll containers, and the authoring helpers for custom widgets.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
//! Page-transition machinery for the [`navigator`](super::navigator): the
//! transition vocabulary ([`PageTransition`] presets + [`Timing`]
//! modes), the per-transition progress [`driver`](TransitionDriver) the navigator
//! advances during paint, the pure geometry ([`resolve_layers`]) that maps a
//! progress value onto per-page paint offsets + opacities, and the published
//! [`TransitionState`] snapshot chrome *outside* the navigator observes a
//! transition through.
//!
//! # Split of concerns
//!
//! This module is the **reusable, page-agnostic** half of the transition system:
//! it knows nothing about the retained page stack. The navigator
//! ([`super::navigator`]) owns the [`ActiveTransition`](super::navigator) that
//! pairs a [`TransitionDriver`] with the concrete incoming/outgoing page pods and
//! drives it from `PaintCtx::frame_time` during paint — see that module for the
//! ownership/lifecycle contract (create per push/pop, dispose on settle, Flutter
//! parity).
//!
//! # Positioning rule (paint offset = pod origin)
//!
//! A slide animates a page's **pod origin** (the navigator writes
//! [`ChildPod::set_origin`](frust_core::ChildPod::set_origin) each paint), so
//! paint and hit-testing move together — the same precedent `ScrollWidget` uses.
//! Opacity is a paint-only effect (`PaintScene::push_layer(alpha)`); it may
//! diverge from hit-testing, which is irrelevant because the navigator blocks all
//! input to pages while a transition runs (see [`super::navigator`]).
//!
//! # Overshoot: spatial vs effects
//!
//! A [`Timing::Spring`] mode drives the progress with an
//! [`AnimationController::fling`](frust_core::AnimationController::fling): an
//! under-damped **spatial** preset (M3's `damping_ratio: 0.9`) genuinely
//! overshoots past `1.0` before settling, and that overshoot is applied to
//! *position* offsets (raw [`value`](TransitionDriver::value)) so the page visibly
//! springs past its resting spot. **Opacity** uses the clamped value so alpha
//! never exceeds `[0, 1]` — a critically-damped effects preset never overshoots
//! anyway, but clamping is the belt-and-braces guarantee (see
//! `frust-core`'s `anim` overshoot contract).

use std::time::Duration;

use frust_core::{AnimationController, Curve, FrameTime, Spring, SpringDesc};
use frust_theme::{MotionScheme, MotionSpring};
use kurbo::{Affine, Point, Rect, Size};

// --- Named preset constants (see per-constant source comments) --------------

/// M3 shared-axis-X slide distance, in logical px (dp). Confirmed spec value:
/// Material 3 motion "shared axis" transitions translate by 30dp along the axis.
/// Source: Material Design 3 motion guidelines (m3.material.io, "Transitions →
/// Shared axis").
const M3_SHARED_AXIS_SLIDE_DP: f64 = 30.0;

/// Progress split between the outgoing fade-out and the incoming fade-in for
/// M3 shared-axis and fade-through transitions: the outgoing page fades out over
/// `[0, THRESHOLD]` and the incoming page fades in over `[THRESHOLD, 1]`.
///
/// M3's "fade through" is defined by *progress fractions* (a fade-out then a
/// fade-in with a brief gap), not fixed millisecond offsets. ~0.35 is the
/// split the reference implementations use.
const M3_FADE_SPLIT: f64 = 0.35;

/// M3 fade-through incoming scale start (the incoming page scales 92% → 100% as
/// it fades in). Source: Material Design 3 "fade through" spec, matching the
/// verified Flutter `FadeThroughTransition` staging.
///
/// Applied on the incoming [`Layer::scale`] over the `[`[`M3_FADE_THROUGH_SPLIT`]`,
/// 1]` segment with [`M3_FADE_THROUGH_IN_CURVE`]; the navigator brackets the
/// incoming page's paint with a `push_transform` when the layer scale differs
/// from `1.0`.
const M3_FADE_THROUGH_SCALE_START: f64 = 0.92;

/// M3 fade-through progress split: the outgoing page finishes its fade-out and
/// the incoming page begins its fade-in + scale-up at this fraction — the
/// verified Flutter `FadeThroughTransition` staging boundary (the first
/// **6/20** of the timeline).
const M3_FADE_THROUGH_SPLIT: f64 = 0.30;

/// M3 fade-through *outgoing* fade-out easing — Flutter `Cubic(0.4,0,1,1)`
/// applied over `[0, `[`M3_FADE_THROUGH_SPLIT`]`]`, after which the outgoing page
/// holds at `0` opacity.
const M3_FADE_THROUGH_OUT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);

/// M3 fade-through *incoming* fade-in + scale-up easing — Flutter
/// `Cubic(0,0,0.2,1)` applied over `[`[`M3_FADE_THROUGH_SPLIT`]`, 1]`. Both the
/// opacity `0→1` and the scale
/// [`M3_FADE_THROUGH_SCALE_START`]`→1.0` track this one eased segment.
const M3_FADE_THROUGH_IN_CURVE: Curve = Curve::Cubic(0.0, 0.0, 0.2, 1.0);

/// Glyph screen-transition slide distance, in logical px ("screen transition:
/// 340ms spatial slide-in 16px + fade").
const GLYPH_SLIDE_DP: f64 = 16.0;

/// Glyph screen-transition *enter* duration — the new screen's spatial slide-in
/// (340ms, the Glyph `slow` token). The unthemed
/// fallback when no [`MotionScheme`] is threaded (see [`preset_enter_exit`]).
const GLYPH_ENTER: Duration = Duration::from_millis(340);

/// Glyph screen-transition *exit* duration — the old screen's accelerate-out
/// (150ms, the Glyph `fast` token) plus the hard rule
/// "exits always faster than entrances". Unthemed fallback.
const GLYPH_EXIT: Duration = Duration::from_millis(150);

/// Glyph `spatial` easing (overshoot; position/scale)
/// (`cubic-bezier(0.34,1.35,0.64,1)`). Unthemed fallback for the enter curve.
const GLYPH_SPATIAL_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);

/// Glyph `exit` easing (accelerate-out)
/// (`cubic-bezier(0.4,0,1,1)`). Unthemed fallback for the exit curve.
const GLYPH_EXIT_CURVE: Curve = Curve::Cubic(0.4, 0.0, 1.0, 1.0);

/// The progress split for the Glyph preset's cross-fade: the leaving page
/// completes its fade-out by this fraction (≈ the 150/340 exit/enter duration
/// ratio), after which the entering page fades in — encoding the "exits always
/// faster than entrances" rule in the single-progress geometry.
const GLYPH_FADE_SPLIT: f64 = 0.44;

/// Reduced-motion collapse duration: the Glyph design system's hard rule that
/// `prefers-reduced-motion` collapses *every* pattern to a `≤120ms` linear
/// crossfade. See [`resolve_spec`].
const REDUCE_MOTION_DURATION: Duration = Duration::from_millis(120);

/// iOS push parallax fraction: the outgoing (below) page slides out by one third
/// of the incoming page's travel while the incoming page slides fully across.
///
/// **Community-approximate**: UIKit's
/// `UINavigationController` push does not publish an exact parallax ratio; 1/3 is
/// the value the community-reverse-engineered reimplementations converge on.
const IOS_PARALLAX_FRACTION: f64 = 1.0 / 3.0;

/// Maximum dim applied to the outgoing (below) page during an iOS push — its
/// opacity drops to `1 - IOS_DIM_MAX` at full cover.
///
/// **Customary, not stock**: a dim scrim under the incoming page is a
/// common embellishment, not a documented UIKit constant. Kept small and
/// approximate.
const IOS_DIM_MAX: f32 = 0.08;

/// iOS push default duration. **Community-approximate** (~0.35s ease-in-out);
/// UIKit's exact interactive-transition timing is private.
const IOS_DEFAULT_DURATION: Duration = Duration::from_millis(350);

/// The default duration for a duration-mode M3 transition (300ms). Source:
/// Material Design 3 motion durations ("long2" ≈ the 300ms shared-axis default).
const M3_DEFAULT_DURATION: Duration = Duration::from_millis(300);

/// The spring used to *settle* a transition that was driven manually (the
/// interactive edge-swipe) but configured in a duration [`Timing`] mode — a duration has no
/// spring to fling with, so a release needs a fallback. M3's default **spatial**
/// preset (`damping_ratio: 0.9`, `stiffness: 700`, mass 1). Source:
/// material-components-android motion tokens (see `frust-theme`'s `motion`).
const DEFAULT_SETTLE_SPRING: SpringDesc = SpringDesc {
    mass: 1.0,
    stiffness: 700.0,
    damping_ratio: 0.9,
};

// --- Public vocabulary ------------------------------------------------------

/// The visual shape of a page transition. The navigator maps the active
/// transition's progress onto per-page geometry through [`resolve_layers`].
///
/// `#[allow(unpredictable_function_pointer_comparisons)]`: the derived
/// `PartialEq`/`Eq` compare a [`PageTransition::Custom`] payload by function
/// pointer address, which the compiler flags because that address isn't
/// guaranteed stable across codegen units. Accepted here deliberately — the
/// contract (documented on `Custom`) is a best-effort "names the same
/// function" identity check, not a memory-safety- or correctness-critical
/// comparison; the alternative (dropping `Eq` from the enum) breaks every
/// other variant's equality for a single edge case. The `#[allow]` sits at
/// the enum level (not scoped to `Custom` alone) because a field-level
/// attribute on a tuple-variant payload does not suppress a lint raised
/// inside the derive macro's generated `PartialEq`/`Eq` impl — confirmed by
/// attempting exactly that scoping, which left the warning in place; the
/// derive expands against the whole enum, so only an enum- or module-level
/// `#[allow]` (or a hand-written `impl PartialEq` dropping the derive
/// entirely) reaches it.
#[allow(unpredictable_function_pointer_comparisons)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PageTransition {
    /// Instant switch — no animation. The default.
    #[default]
    None,
    /// Material 3 shared-axis-X: a 30dp slide paired with a threshold cross-fade.
    M3SharedAxisX,
    /// Material 3 fade-through: a *staged* outgoing fade-out then incoming
    /// fade-in **with** the incoming [`M3_FADE_THROUGH_SCALE_START`]`→1.0`
    /// scale-up — the verified Flutter `FadeThroughTransition` staging.
    /// See [`resolve_layers`]'s `M3FadeThrough` arm.
    M3FadeThrough,
    /// iOS-style push/pop: incoming slides full-width from the edge; outgoing
    /// parallaxes by [`IOS_PARALLAX_FRACTION`] with an optional dim.
    IosPush,
    /// Bottom-sheet-style slide-up: the entering page translates in from the
    /// bottom (full height → 0); pop reverses (slides back down and out). The
    /// page **below** never moves (a modal sheet floats over a static page,
    /// unlike [`PageTransition::IosPush`]'s parallaxing below-page). Opacity is
    /// always `1.0` for both layers — a sheet's scrim is a **page-owned** paint
    /// concern (the hosting widget paints/fades its own scrim, e.g. reading the
    /// transition progress itself or a fixed alpha), not a [`Layer`]-level
    /// effect this preset drives; see [`resolve_layers`]'s `SlideUp` arm.
    SlideUp,
    /// Glyph screen transition: a directional
    /// [`GLYPH_SLIDE_DP`]-px slide paired with a cross-fade. The entering screen
    /// slides in over the theme's *slow* spatial timing while the leaving screen
    /// accelerates out over the faster *exit* timing ("exits always faster than
    /// entrances"); a pop reverses the slide direction. Timing is resolved from
    /// the active [`MotionScheme`] via [`resolve_spec`]/[`preset_enter_exit`] —
    /// pair it with [`Timing::ThemeDefault`] (or [`TransitionSpec::glyph`]).
    Glyph,
    /// Pure alpha cross-fade with **zero geometric motion** (no slide, no
    /// scale) — the [`resolve_spec`] `reduce_motion` collapse target
    /// (the Glyph design system's hard accessibility rule: a reduced transition
    /// is a short linear cross-fade, never a zoom or slide). Selectable directly,
    /// but its primary role is the collapse; see [`resolve_layers`]'s arm.
    ReducedCrossfade,
    /// A caller-supplied transition: a third-party design system's escape hatch
    /// for authoring its own page transition without a matching built-in
    /// preset. The function maps **raw** progress (unclamped, so a spring
    /// overshoot is visible — the same raw `value` [`resolve_layers`] passes
    /// every built-in preset for position) plus `is_pop` and the page `Size`
    /// onto the `(entering, leaving)` [`Layer`] pair, exactly as a built-in
    /// preset's `resolve_layers` arm does; [`resolve_layers`] invokes it
    /// verbatim, with no clamping or post-processing beyond what every preset
    /// gets.
    ///
    /// A plain function pointer (not `Box<dyn Fn>`) so `Custom` keeps every
    /// derive `PageTransition` already has (`Copy`/`Eq` included) — a
    /// `Box<dyn Fn>` cannot implement either. Function pointers compare by
    /// address, so `PartialEq`/`Eq` stay meaningful: two `Custom` specs are
    /// equal iff they name the same function.
    ///
    /// **Timing.** `Custom` has no [`MotionScheme`] tokens of its own — pair it
    /// with an explicit [`Timing::Duration`]/[`Timing::Spring`] for a
    /// caller-chosen timing, or [`Timing::ThemeDefault`] to fall back to the
    /// documented M3 default (300ms + [`Curve::Emphasized`] —
    /// [`preset_enter_exit`]'s fallback arm, matching [`make_driver`]'s
    /// unthemed `ThemeDefault` fallback).
    ///
    /// **Reduce-motion — programmatic path only.** [`resolve_spec`]'s
    /// collapse to [`PageTransition::ReducedCrossfade`] applies to `Custom`
    /// like every other preset when a transition is staged
    /// **programmatically** (a push/pop/replace carrying a
    /// [`TransitionSpec`]): the navigator resolves it against the active
    /// `reduce_motion` flag on the transition's first paint, before the
    /// preset is ever consulted, so a `Custom` fn staged that way is not
    /// called.
    ///
    /// A **user-driven interactive edge-swipe pop** is a different path:
    /// it is not routed through that collapse at all — the navigator drives
    /// the popped page's own preset directly, raw, with no
    /// [`resolve_spec`] step — so under `reduce_motion` the supplied
    /// function **is** still called there. This is not specific to
    /// `Custom`: every built-in preset behaves identically on an
    /// interactive pop (a pre-existing accessibility gap, unrelated to
    /// `Custom` and tracked separately — this doc narrows the claim to what
    /// the code does, it does not fix the gap).
    Custom(fn(progress: f64, is_pop: bool, size: Size) -> (Layer, Layer)),
}

/// How a transition's `0.0..=1.0` progress is driven.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Timing {
    /// Duration + easing curve (bounded, no overshoot).
    Duration(Duration, Curve),
    /// A physics spring ([`MotionSpring`], from the theme's motion scheme). A
    /// spatial preset overshoots position; an effects preset does not.
    Spring(MotionSpring),
    /// Resolve concrete timing from the active theme's [`MotionScheme`] (the
    /// durations/easing tokens), per preset and honoring `reduce_motion`. The
    /// navigator resolves this via [`resolve_spec`] on the transition's **first
    /// paint** — the first point a `PaintCtx` carries the theme (the `BuildCtx`
    /// that stages a transition carries none) — and rebuilds the driver from the
    /// resolved timing before any frame is staged. If no theme is threaded it
    /// falls back through [`make_driver`] to the M3 default duration +
    /// [`Curve::Emphasized`] easing.
    ThemeDefault,
}

impl Default for Timing {
    fn default() -> Self {
        Timing::Duration(M3_DEFAULT_DURATION, Curve::EaseInOut)
    }
}

/// A transition selection: which [`PageTransition`] shape, driven by which
/// [`Timing`]. Attached per-push/replace (or defaulted at the navigator level);
/// a pop reverses the popped page's stored spec.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransitionSpec {
    /// The visual preset.
    pub preset: PageTransition,
    /// How its progress is driven.
    pub timing: Timing,
}

impl Default for TransitionSpec {
    fn default() -> Self {
        Self::NONE
    }
}

impl TransitionSpec {
    /// An instant (non-animated) switch — the navigator's default.
    pub const NONE: Self = TransitionSpec {
        preset: PageTransition::None,
        timing: Timing::Duration(M3_DEFAULT_DURATION, Curve::EaseInOut),
    };

    /// A transition with an explicit preset and timing.
    pub const fn new(preset: PageTransition, timing: Timing) -> Self {
        TransitionSpec { preset, timing }
    }

    /// A duration-driven transition with the preset's natural default duration
    /// and an ease-in-out curve.
    pub fn duration(preset: PageTransition) -> Self {
        let d = match preset {
            PageTransition::IosPush => IOS_DEFAULT_DURATION,
            // SlideUp is a Material-family surface (a bottom sheet), not an iOS
            // one, so it follows the same M3 "long2" 300ms default the other
            // two Material presets use here — a duration-mode default, not a
            // theme spring, purely to keep this table uniform; an app wanting a
            // bouncier sheet can still opt into `TransitionSpec::spring` with
            // any `MotionSpring` preset (e.g. the theme's `default_spatial`),
            // same as the other presets.
            _ => M3_DEFAULT_DURATION,
        };
        TransitionSpec {
            preset,
            timing: Timing::Duration(d, Curve::EaseInOut),
        }
    }

    /// A spring-driven transition using a theme [`MotionSpring`] preset (use a
    /// *spatial* preset for a visible overshoot, an *effects* preset for none).
    pub fn spring(preset: PageTransition, spring: MotionSpring) -> Self {
        TransitionSpec {
            preset,
            timing: Timing::Spring(spring),
        }
    }

    /// A theme-timed transition: the preset's timing is resolved from the active
    /// [`MotionScheme`] (via [`resolve_spec`]) on the transition's first paint,
    /// honoring `reduce_motion`. The idiomatic constructor for
    /// [`PageTransition::Glyph`].
    pub const fn themed(preset: PageTransition) -> Self {
        TransitionSpec {
            preset,
            timing: Timing::ThemeDefault,
        }
    }

    /// The Glyph screen transition with theme-resolved
    /// timing — shorthand for
    /// [`TransitionSpec::themed`]`(`[`PageTransition::Glyph`]`)`.
    pub const fn glyph() -> Self {
        Self::themed(PageTransition::Glyph)
    }

    /// Whether this spec animates at all (`false` for [`PageTransition::None`]).
    pub fn is_animated(&self) -> bool {
        self.preset != PageTransition::None
    }
}

// --- Published transition snapshot ------------------------------------------

/// A snapshot of the navigator's single in-flight page transition, published by
/// [`NavigatorWidget`](super::navigator::NavigatorWidget) and read through
/// [`NavigatorController::transition`](super::navigator::NavigatorController::transition).
///
/// Plain `Copy` data — `Send + Sync` **by construction** (every field is a
/// primitive), so an app may mirror it into an `RwSignal`, hand it across
/// `provide_context`, or read it directly. `frust-widgets` stays reactive-free:
/// the navigator publishes this into a plain `Rc<Cell<TransitionState>>`, exactly
/// as it publishes [`depth`](super::navigator::NavigatorController::depth); any
/// signal bridging is the facade's job.
///
/// # Timing
///
/// The full read-timing contract (which pass sees an exact value and which sees
/// a one-frame-stale one) is documented on
/// [`NavigatorController::transition`](super::navigator::NavigatorController::transition)
/// — read it before choreographing anything against `progress`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransitionState {
    /// Whether a page transition is in flight right now.
    pub active: bool,
    /// The RAW driver value, `0.0` → `1.0`. A spatial spring genuinely
    /// overshoots past `1.0` (see the module docs' overshoot note) — use
    /// [`clamped`](Self::clamped) for anything driving opacity.
    pub progress: f64,
    /// `true` when the transition runs *backwards* (a pop or an interactive
    /// edge-swipe back); `false` for a push/replace.
    pub is_pop: bool,
    /// An interactive edge-swipe is holding the progress (the drag pins it
    /// between frames rather than a driver advancing it). Cleared when the
    /// swipe is released into its settle spring.
    pub interactive: bool,
    /// The page-stack depth the transition is leaving.
    pub from_depth: usize,
    /// The page-stack depth the transition is arriving at. Always the navigator's
    /// *current* `pages.len()` — a push/pop/replace mutates the stack up front and
    /// animates afterwards, so the stack is the destination from the first frame.
    pub to_depth: usize,
    /// Bumped once per transition started. Distinguishes "the same transition,
    /// later" from "a new transition at the same progress" — the discriminator a
    /// chrome observer needs to reset its own per-transition state. Wraps.
    pub generation: u32,
}

impl TransitionState {
    /// The at-rest snapshot for a settled stack of `depth` pages: nothing in
    /// flight, `from_depth == to_depth == depth`.
    ///
    /// `progress` is `1.0` — "fully arrived". With `from_depth == to_depth` the
    /// value is degenerate (both endpoints are the same stack), so a reader that
    /// ignores [`active`](Self::active) still sees the destination rather than a
    /// jump back to the origin.
    pub const fn settled(depth: usize, generation: u32) -> Self {
        TransitionState {
            active: false,
            progress: 1.0,
            is_pop: false,
            interactive: false,
            from_depth: depth,
            to_depth: depth,
            generation,
        }
    }

    /// [`progress`](Self::progress) clamped to `[0.0, 1.0]` — the value to drive
    /// opacity (or any other bounded quantity) with, since a spatial spring's raw
    /// progress overshoots.
    pub fn clamped(&self) -> f64 {
        self.progress.clamp(0.0, 1.0)
    }
}

impl Default for TransitionState {
    /// The at-rest snapshot of an empty stack — what a
    /// [`NavigatorController`](super::navigator::NavigatorController) reads before
    /// any navigator attaches to it.
    fn default() -> Self {
        Self::settled(0, 0)
    }
}

// Compile-time proof of the property the whole seam rests on: the published
// snapshot is `Send + Sync` BY CONSTRUCTION, so it can ride `provide_context`
// (which requires `T: Send + Sync`) or be mirrored into an `RwSignal` — unlike
// the `Rc`-backed `NavigatorController` that hands it out.
const _: fn() = || {
    fn assert_send_sync<T: Send + Sync + 'static>() {}
    assert_send_sync::<TransitionState>();
};

// --- Progress driver --------------------------------------------------------

/// The result of advancing a [`TransitionDriver`] one frame.
#[derive(Clone, Copy, Debug)]
pub struct Advance {
    /// The progress value this frame (may exceed `[0, 1]` mid-overshoot for a
    /// spatial spring).
    pub value: f64,
    /// Whether the driver is still moving (the caller should request another
    /// frame).
    pub animating: bool,
    /// Whether the driver has reached its resting target this frame (the
    /// navigator finalizes the transition on the next rebuild).
    pub done: bool,
}

/// Drives a transition's `0.0..=1.0` progress. The programmatic push/pop path
/// uses [`Auto`](Self::Auto) (an [`AnimationController`] advanced during paint);
/// the [`Held`](Self::Held)/[`Settle`](Self::Settle) variants are the seam the
/// interactive edge-swipe gesture drives (`set_progress`/`settle` on the navigator).
#[derive(Clone, Copy, Debug)]
pub enum TransitionDriver {
    /// Programmatic drive: an [`AnimationController`] (duration or spring fling)
    /// advanced from the frame clock.
    Auto(AnimationController),
    /// Externally pinned progress (drag-in-progress): paint reads `value`
    /// verbatim and never advances; the transition stays alive (paused).
    Held { value: f64 },
    /// A released spring settle (fling): an analytic [`Spring`] released
    /// from the held value toward `target`, advanced by frame-time differencing.
    Settle {
        spring: Spring,
        target: f64,
        elapsed: f64,
        last: Option<FrameTime>,
    },
}

impl TransitionDriver {
    /// The current progress value (raw — may overshoot for a spatial spring).
    pub fn value(&self) -> f64 {
        match self {
            TransitionDriver::Auto(c) => c.value(),
            TransitionDriver::Held { value } => *value,
            TransitionDriver::Settle {
                spring,
                target,
                elapsed,
                ..
            } => target + spring.position(*elapsed),
        }
    }

    /// Advance to frame time `now`, returning this frame's [`Advance`].
    pub fn advance(&mut self, now: FrameTime) -> Advance {
        match self {
            TransitionDriver::Auto(c) => {
                let animating = c.advance(now);
                Advance {
                    value: c.value(),
                    animating,
                    done: !animating,
                }
            }
            TransitionDriver::Held { value } => Advance {
                value: *value,
                animating: false,
                done: false,
            },
            TransitionDriver::Settle {
                spring,
                target,
                elapsed,
                last,
            } => {
                let dt = match *last {
                    Some(prev) => now.saturating_sub(prev).as_secs_f64(),
                    None => 0.0,
                };
                *last = Some(now);
                *elapsed += dt.max(0.0);
                let e = *elapsed;
                if spring.is_at_rest(e, 1e-3) {
                    Advance {
                        value: *target,
                        animating: false,
                        done: true,
                    }
                } else {
                    Advance {
                        value: *target + spring.position(e),
                        animating: true,
                        done: false,
                    }
                }
            }
        }
    }
}

/// Build a fresh [`TransitionDriver`] (already running `0 → 1`) plus the spring
/// to use for a later manual settle, from a [`Timing`].
pub fn make_driver(timing: Timing) -> (TransitionDriver, SpringDesc) {
    match timing {
        Timing::Duration(d, curve) => {
            let mut c = AnimationController::new(d).with_curve(curve);
            c.forward();
            (TransitionDriver::Auto(c), DEFAULT_SETTLE_SPRING)
        }
        Timing::Spring(spring) => {
            let desc: SpringDesc = spring.into();
            // Duration is irrelevant for a fling; the controller starts at 0 and
            // flings toward 1 with zero release velocity (a spatial preset still
            // overshoots — that is the point of a bouncy transition).
            let mut c = AnimationController::new(M3_DEFAULT_DURATION);
            c.fling(0.0, desc);
            (TransitionDriver::Auto(c), desc)
        }
        Timing::ThemeDefault => {
            // Normally pre-resolved by `resolve_spec` against the active theme
            // before the driver is built; an unresolved `ThemeDefault` reaching
            // here (no theme threaded) falls back to the M3 default duration +
            // emphasized easing.
            make_driver(Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized))
        }
    }
}

/// The (enter, exit) driver [`Timing`]s a directional preset resolves to from
/// the active `scheme` (or the unthemed fallback constants when `None`).
///
/// `enter` is the longer *spatial* motion (new content arriving); `exit` is the
/// faster accelerate-out (old content leaving) — Glyph's "exits always faster
/// than entrances" rule. For [`PageTransition::Glyph`] these are
/// the theme's `slow`/`fast` durations with the `spatial`/`exit` easings
/// (340ms spatial in / 150ms exit out); every other
/// preset reuses its natural [`TransitionSpec::duration`] timing for both,
/// including [`PageTransition::Custom`], which has no theme tokens of its own
/// and so falls back to the documented M3 default (300ms +
/// [`Curve::Emphasized`], matching [`make_driver`]'s unthemed `ThemeDefault`
/// fallback).
pub fn preset_enter_exit(
    preset: PageTransition,
    scheme: Option<&MotionScheme>,
) -> (Timing, Timing) {
    match preset {
        PageTransition::Glyph => match scheme {
            Some(s) => (
                Timing::Duration(
                    Duration::from_secs_f64(s.durations.slow / 1000.0),
                    s.easing.spatial,
                ),
                Timing::Duration(
                    Duration::from_secs_f64(s.durations.fast / 1000.0),
                    s.easing.exit,
                ),
            ),
            None => (
                Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE),
                Timing::Duration(GLYPH_EXIT, GLYPH_EXIT_CURVE),
            ),
        },
        PageTransition::Custom(_) => {
            // No theme tokens of its own: the documented M3 default fallback,
            // the same one `make_driver` uses for an unresolved `ThemeDefault`.
            let d = Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized);
            (d, d)
        }
        other => {
            let d = TransitionSpec::duration(other).timing;
            (d, d)
        }
    }
}

/// Resolve a spec's [`Timing`] into the concrete timing the driver runs, given
/// the preset and the active `scheme`. A [`Timing::ThemeDefault`] becomes the
/// preset's *enter* timing ([`preset_enter_exit`]`.0`) — the dominant, longer
/// motion the single progress driver runs; the faster exit is expressed by the
/// geometry's cross-fade split ([`GLYPH_FADE_SPLIT`]). An explicit
/// `Duration`/`Spring` passes through unchanged. `reduce_motion` collapse is
/// applied at the spec level by [`resolve_spec`], not here.
pub fn resolve_timing(
    timing: Timing,
    preset: PageTransition,
    scheme: Option<&MotionScheme>,
) -> Timing {
    match timing {
        Timing::ThemeDefault => preset_enter_exit(preset, scheme).0,
        other => other,
    }
}

/// Resolve a [`TransitionSpec`] against the active `scheme` into the concrete
/// spec the navigator drives:
///
/// - `scheme.reduce_motion == true` collapses *any* animated preset to a
///   `≤120ms` linear cross-fade ([`PageTransition::ReducedCrossfade`] driven by
///   [`REDUCE_MOTION_DURATION`] + [`Curve::Linear`] — pure alpha, no slide or
///   scale) — the Glyph design system's hard accessibility rule. A non-animated
///   ([`PageTransition::None`]) spec is left untouched.
/// - otherwise a [`Timing::ThemeDefault`] is resolved to the preset's theme
///   timing ([`resolve_timing`]); the preset is unchanged.
pub fn resolve_spec(spec: TransitionSpec, scheme: Option<&MotionScheme>) -> TransitionSpec {
    if spec.is_animated() && scheme.map(|s| s.reduce_motion).unwrap_or(false) {
        return TransitionSpec {
            preset: PageTransition::ReducedCrossfade,
            timing: Timing::Duration(REDUCE_MOTION_DURATION, Curve::Linear),
        };
    }
    TransitionSpec {
        preset: spec.preset,
        timing: resolve_timing(spec.timing, spec.preset, scheme),
    }
}

/// Build a [`TransitionDriver::Settle`] that springs from `from` toward `target`
/// with initial `velocity` — the navigator's `settle(velocity)` seam.
pub fn settle_driver(
    spring: SpringDesc,
    from: f64,
    velocity: f64,
    target: f64,
) -> TransitionDriver {
    TransitionDriver::Settle {
        spring: Spring::new(spring, from - target, velocity),
        target,
        elapsed: 0.0,
        last: None,
    }
}

// --- Geometry ---------------------------------------------------------------

/// Per-page paint parameters for one frame of a transition: a paint offset
/// (applied to the page's pod origin) and an opacity.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Layer {
    /// Horizontal paint offset in logical px (added to the page's pod origin).
    pub dx: f64,
    /// Vertical paint offset in logical px (added to the page's pod origin).
    /// Every preset before [`PageTransition::SlideUp`] is horizontal-only and
    /// leaves this at `0.0`.
    pub dy: f64,
    /// Opacity in `[0, 1]` (composited via `PaintScene::push_layer`).
    pub alpha: f32,
    /// Uniform scale factor about the page's paint area, composited via
    /// [`PaintScene::push_transform`](frust_core::PaintScene::push_transform).
    /// `1.0` for every preset except [`PageTransition::M3FadeThrough`], whose
    /// incoming page scales [`M3_FADE_THROUGH_SCALE_START`]`→1.0` as it fades in.
    /// The navigator brackets the page's paint with the scale
    /// transform when this differs from `1.0`.
    pub scale: f64,
}

impl Layer {
    /// A fully-visible, un-offset, un-scaled layer.
    pub const IDENTITY: Layer = Layer {
        dx: 0.0,
        dy: 0.0,
        alpha: 1.0,
        scale: 1.0,
    };
}

/// Resolve the (entering, leaving) [`Layer`]s for a transition preset at progress
/// `value`.
///
/// - `entering` is the page that becomes top after the op (a push's new page, or
///   a pop's revealed page); `leaving` is the page losing the top.
/// - `value` is raw (used for **position**, so a spatial spring's overshoot shows
///   in the slide); opacity uses the `[0, 1]`-clamped value.
/// - `is_pop` reverses the horizontal direction (a pop slides the opposite way).
pub fn resolve_layers(
    preset: PageTransition,
    value: f64,
    is_pop: bool,
    size: Size,
) -> (Layer, Layer) {
    let p = value; // raw (overshoot allowed) — used for position
    let pc = value.clamp(0.0, 1.0); // clamped — used for opacity
    let w = size.width;

    match preset {
        PageTransition::None => (Layer::IDENTITY, Layer::IDENTITY),

        PageTransition::ReducedCrossfade => {
            // reduce_motion collapse target: pure alpha cross-fade — by
            // contract NO geometric motion (dx/dy 0, scale 1.0) so a
            // reduced-motion user never sees a zoom or slide.
            let entering = Layer {
                dx: 0.0,
                dy: 0.0,
                alpha: pc as f32,
                scale: 1.0,
            };
            let leaving = Layer {
                dx: 0.0,
                dy: 0.0,
                alpha: (1.0 - pc) as f32,
                scale: 1.0,
            };
            (entering, leaving)
        }

        PageTransition::M3SharedAxisX => {
            let slide = M3_SHARED_AXIS_SLIDE_DP;
            // Push: entering enters from +30dp; pop: from -30dp (mirror).
            let dir = if is_pop { -1.0 } else { 1.0 };
            let entering = Layer {
                dx: dir * (1.0 - p) * slide,
                dy: 0.0,
                alpha: ramp(pc, M3_FADE_SPLIT, 1.0),
                scale: 1.0,
            };
            let leaving = Layer {
                dx: -dir * p * slide,
                dy: 0.0,
                alpha: 1.0 - ramp(pc, 0.0, M3_FADE_SPLIT),
                scale: 1.0,
            };
            (entering, leaving)
        }

        PageTransition::M3FadeThrough => {
            // Verified Flutter `FadeThroughTransition` staging:
            // the outgoing page fades 1→0 over the first 6/20 of the timeline
            // (`Cubic(0.4,0,1,1)`) then holds; the incoming page holds at
            // `M3_FADE_THROUGH_SCALE_START` scale / 0 opacity for that 6/20,
            // then fades in AND scales to 1.0 over the remaining 14/20
            // (`Cubic(0,0,0.2,1)`) — opacity and scale track one shared segment.
            let out = M3_FADE_THROUGH_OUT_CURVE.interval(0.0, M3_FADE_THROUGH_SPLIT);
            let inc = M3_FADE_THROUGH_IN_CURVE.interval(M3_FADE_THROUGH_SPLIT, 1.0);
            let in_progress = inc.transform(pc);
            let entering = Layer {
                dx: 0.0,
                dy: 0.0,
                alpha: in_progress as f32,
                scale: M3_FADE_THROUGH_SCALE_START
                    + (1.0 - M3_FADE_THROUGH_SCALE_START) * in_progress,
            };
            let leaving = Layer {
                dx: 0.0,
                dy: 0.0,
                alpha: (1.0 - out.transform(pc)) as f32,
                scale: 1.0,
            };
            (entering, leaving)
        }

        PageTransition::Glyph => {
            // Directional 16px slide + cross-fade.
            // Push: entering enters from +16px; pop: from -16px (back reverses).
            // The leaving page completes its fade by `GLYPH_FADE_SPLIT`, encoding
            // "exits always faster than entrances" in the single-progress geometry;
            // the theme-resolved enter/exit *durations* live in `preset_enter_exit`.
            let slide = GLYPH_SLIDE_DP;
            let dir = if is_pop { -1.0 } else { 1.0 };
            let entering = Layer {
                dx: dir * (1.0 - p) * slide,
                dy: 0.0,
                alpha: ramp(pc, GLYPH_FADE_SPLIT, 1.0),
                scale: 1.0,
            };
            let leaving = Layer {
                dx: -dir * p * slide,
                dy: 0.0,
                alpha: 1.0 - ramp(pc, 0.0, GLYPH_FADE_SPLIT),
                scale: 1.0,
            };
            (entering, leaving)
        }

        PageTransition::IosPush => {
            if is_pop {
                // Revealed page slides back from -parallax to 0; popped page
                // slides fully off to the right.
                let entering = Layer {
                    dx: -(1.0 - p) * w * IOS_PARALLAX_FRACTION,
                    dy: 0.0,
                    alpha: 1.0,
                    scale: 1.0,
                };
                let leaving = Layer {
                    dx: p * w,
                    dy: 0.0,
                    alpha: 1.0,
                    scale: 1.0,
                };
                (entering, leaving)
            } else {
                // Incoming slides full-width from the right; below page
                // parallaxes left and dims.
                let entering = Layer {
                    dx: (1.0 - p) * w,
                    dy: 0.0,
                    alpha: 1.0,
                    scale: 1.0,
                };
                let leaving = Layer {
                    dx: -p * w * IOS_PARALLAX_FRACTION,
                    dy: 0.0,
                    alpha: 1.0 - pc as f32 * IOS_DIM_MAX,
                    scale: 1.0,
                };
                (entering, leaving)
            }
        }

        PageTransition::SlideUp => {
            let h = size.height;
            if is_pop {
                // The revealed page below never moved while covered (see the
                // enum docs) — it stays at rest, full opacity, the whole time.
                // The popped sheet (leaving) slides from rest back down and out.
                let entering = Layer {
                    dx: 0.0,
                    dy: 0.0,
                    alpha: 1.0,
                    scale: 1.0,
                };
                let leaving = Layer {
                    dx: 0.0,
                    dy: p * h,
                    alpha: 1.0,
                    scale: 1.0,
                };
                (entering, leaving)
            } else {
                // The entering sheet slides up from the bottom (full height
                // offset) to rest; the page below stays static and fully
                // opaque throughout (no parallax/dim, unlike `IosPush`).
                let entering = Layer {
                    dx: 0.0,
                    dy: (1.0 - p) * h,
                    alpha: 1.0,
                    scale: 1.0,
                };
                let leaving = Layer {
                    dx: 0.0,
                    dy: 0.0,
                    alpha: 1.0,
                    scale: 1.0,
                };
                (entering, leaving)
            }
        }

        PageTransition::Custom(f) => f(p, is_pop, size),
    }
}

// --- Shared-element ("hero") morph geometry -----------------------

/// Linearly interpolate two rects — origin and size independently — at `t`.
///
/// The shared-element ("hero") morph interpolates a tagged element's source
/// rect (its rest position on the outgoing page) toward its destination rect
/// (its rest position on the incoming page) each transition frame. The caller
/// clamps `t` to `[0, 1]` when a well-defined (non-negative-extent) rect is
/// required — a spatial-spring overshoot past `1.0` would otherwise flip a
/// dimension.
pub fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect {
    let lerp = |a: f64, b: f64| a + (b - a) * t;
    Rect::from_origin_size(
        Point::new(lerp(from.x0, to.x0), lerp(from.y0, to.y0)),
        Size::new(
            lerp(from.width(), to.width()),
            lerp(from.height(), to.height()),
        ),
    )
}

/// The affine transform mapping rect `from` onto rect `to`: a translation plus
/// a non-uniform scale taken about `from`'s top-left, so `from`'s corners land
/// exactly on `to`'s.
///
/// A hero wrapper pushes this ([`PaintScene::push_transform`](frust_core::PaintScene::push_transform))
/// to repaint its retained subtree at the interpolated morph rect — position
/// **and** scale, the real morph the pure origin-offset seam every other paint
/// call uses cannot express. A zero-extent `from` on an axis degenerates to an
/// identity scale on that axis (no division by zero).
pub fn rect_to_rect(from: Rect, to: Rect) -> Affine {
    let sx = if from.width().abs() > f64::EPSILON {
        to.width() / from.width()
    } else {
        1.0
    };
    let sy = if from.height().abs() > f64::EPSILON {
        to.height() / from.height()
    } else {
        1.0
    };
    Affine::translate((to.x0, to.y0))
        * Affine::scale_non_uniform(sx, sy)
        * Affine::translate((-from.x0, -from.y0))
}

/// A linear ramp: `0` at `start`, `1` at `end`, clamped outside. Used for the
/// threshold cross-fades. `start == end` degenerates to a step at `start`.
fn ramp(t: f64, start: f64, end: f64) -> f32 {
    if end <= start {
        return if t >= start { 1.0 } else { 0.0 };
    }
    (((t - start) / (end - start)).clamp(0.0, 1.0)) as f32
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ft_secs(s: f64) -> FrameTime {
        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
    }

    const SIZE: Size = Size::new(400.0, 800.0);

    #[test]
    fn transition_state_settled_is_at_rest_on_one_depth() {
        let s = TransitionState::settled(3, 7);
        assert!(!s.active);
        assert!(!s.is_pop);
        assert!(!s.interactive);
        assert_eq!((s.from_depth, s.to_depth), (3, 3));
        assert_eq!(s.generation, 7);
        assert_eq!(s.progress, 1.0, "settled means fully arrived");
        assert_eq!(
            TransitionState::default(),
            TransitionState::settled(0, 0),
            "the default is an at-rest empty stack (no navigator attached)"
        );
    }

    #[test]
    fn transition_state_clamped_bounds_a_spring_overshoot() {
        // A spatial spring genuinely overshoots; `progress` keeps the raw value
        // (so a slide visibly springs past rest) and `clamped` is what drives
        // opacity.
        let mut s = TransitionState::settled(1, 0);
        s.progress = 1.08;
        assert_eq!(s.clamped(), 1.0);
        s.progress = -0.04;
        assert_eq!(s.clamped(), 0.0);
        s.progress = 0.42;
        assert_eq!(s.clamped(), 0.42);
    }

    #[test]
    fn ramp_is_clamped_linear() {
        assert_eq!(ramp(0.0, 0.35, 1.0), 0.0);
        assert_eq!(ramp(0.35, 0.35, 1.0), 0.0);
        assert_eq!(ramp(1.0, 0.35, 1.0), 1.0);
        assert!((ramp(0.675, 0.35, 1.0) - 0.5).abs() < 1e-6);
        // Degenerate window → step.
        assert_eq!(ramp(0.1, 0.5, 0.5), 0.0);
        assert_eq!(ramp(0.9, 0.5, 0.5), 1.0);
    }

    #[test]
    fn shared_axis_slides_and_crossfades_forward() {
        // At the start, entering is offset by the full slide and invisible;
        // leaving is at rest and fully opaque.
        let (enter, leave) = resolve_layers(PageTransition::M3SharedAxisX, 0.0, false, SIZE);
        assert_eq!(enter.dx, M3_SHARED_AXIS_SLIDE_DP);
        assert_eq!(enter.alpha, 0.0);
        assert_eq!(leave.dx, 0.0);
        assert_eq!(leave.alpha, 1.0);

        // At the end, entering rests at 0 and is fully opaque; leaving is fully
        // slid out and transparent.
        let (enter, leave) = resolve_layers(PageTransition::M3SharedAxisX, 1.0, false, SIZE);
        assert_eq!(enter.dx, 0.0);
        assert_eq!(enter.alpha, 1.0);
        assert_eq!(leave.dx, -M3_SHARED_AXIS_SLIDE_DP);
        assert_eq!(leave.alpha, 0.0);
    }

    #[test]
    fn shared_axis_pop_mirrors_direction() {
        let (enter, _leave) = resolve_layers(PageTransition::M3SharedAxisX, 0.0, true, SIZE);
        // Pop: entering comes from the *left* (negative offset).
        assert_eq!(enter.dx, -M3_SHARED_AXIS_SLIDE_DP);
    }

    #[test]
    fn ios_push_incoming_full_width_and_outgoing_parallax() {
        let (enter, leave) = resolve_layers(PageTransition::IosPush, 0.0, false, SIZE);
        // Incoming starts one full width to the right; below page at rest.
        assert_eq!(enter.dx, SIZE.width);
        assert_eq!(leave.dx, 0.0);

        let (enter, leave) = resolve_layers(PageTransition::IosPush, 1.0, false, SIZE);
        assert_eq!(enter.dx, 0.0);
        // Below page parallaxes by 1/3 width and is dimmed.
        assert!((leave.dx + SIZE.width * IOS_PARALLAX_FRACTION).abs() < 1e-9);
        assert!(leave.alpha < 1.0);
    }

    #[test]
    fn fade_through_has_no_horizontal_slide() {
        // Fade-through is a staged fade + incoming scale-up (verified Flutter
        // `FadeThroughTransition` staging), never a slide: both
        // pages stay horizontally at rest at every progress.
        let (enter, leave) = resolve_layers(PageTransition::M3FadeThrough, 0.5, false, SIZE);
        assert_eq!(enter.dx, 0.0);
        assert_eq!(leave.dx, 0.0);
    }

    #[test]
    fn fade_through_staged_opacity_and_scale_table() {
        // Verified Flutter `FadeThroughTransition` staging:
        // outgoing fades 1→0 over the first 6/20 (`Cubic(0.4,0,1,1)`) then holds;
        // incoming holds at 0.92 scale / 0 opacity for 6/20 then fades in AND
        // scales to 1.0 over the remaining 14/20 (`Cubic(0,0,0.2,1)`).
        // Split = 6/20 = 0.30. Table at t ∈ {0, 0.3, 0.65, 1.0} for BOTH pages.
        let ft = PageTransition::M3FadeThrough;

        // t = 0: outgoing fully opaque at rest scale; incoming invisible at 0.92.
        let (enter, leave) = resolve_layers(ft, 0.0, false, SIZE);
        assert_eq!(leave.alpha, 1.0);
        assert_eq!(leave.scale, 1.0);
        assert_eq!(enter.alpha, 0.0);
        assert!((enter.scale - 0.92).abs() < 1e-9);

        // t = 0.30 (the 6/20 split): outgoing has just finished fading (0);
        // incoming's window opens here — still 0 opacity / 0.92 scale.
        let (enter, leave) = resolve_layers(ft, 0.30, false, SIZE);
        assert!(leave.alpha.abs() < 1e-6, "outgoing gone by the split");
        assert_eq!(enter.alpha, 0.0, "incoming fade-in opens at the split");
        assert!((enter.scale - 0.92).abs() < 1e-9);

        // t = 0.65: outgoing long gone; incoming mid fade-in. Local progress into
        // the [0.30, 1.0] segment is (0.65-0.30)/0.70 = 0.5, eased by
        // `Cubic(0,0,0.2,1)`. Opacity and scale track that one eased value.
        let (enter, leave) = resolve_layers(ft, 0.65, false, SIZE);
        assert_eq!(leave.alpha, 0.0);
        let eased = Curve::Cubic(0.0, 0.0, 0.2, 1.0).transform(0.5);
        assert!((enter.alpha as f64 - eased).abs() < 1e-6);
        assert!((enter.scale - (0.92 + 0.08 * eased)).abs() < 1e-9);
        // Flutter staging sanity: ≈0.84 opacity / ≈0.987 scale at this point.
        assert!((enter.alpha as f64 - 0.839).abs() < 2e-2);
        assert!((enter.scale - 0.987).abs() < 2e-2);

        // t = 1.0: incoming fully arrived (opaque, unit scale); outgoing gone.
        let (enter, leave) = resolve_layers(ft, 1.0, false, SIZE);
        assert_eq!(enter.alpha, 1.0);
        assert!((enter.scale - 1.0).abs() < 1e-9);
        assert_eq!(leave.alpha, 0.0);
        assert_eq!(leave.scale, 1.0);
    }

    #[test]
    fn glyph_slides_directionally_and_crossfades() {
        // A 16px directional slide + cross-fade, no scale.
        let g = PageTransition::Glyph;
        // Push start: entering offset by the full slide, invisible; leaving at rest.
        let (enter, leave) = resolve_layers(g, 0.0, false, SIZE);
        assert_eq!(enter.dx, GLYPH_SLIDE_DP);
        assert_eq!(enter.alpha, 0.0);
        assert_eq!(enter.scale, 1.0);
        assert_eq!(leave.dx, 0.0);
        assert_eq!(leave.alpha, 1.0);
        // Push end: entering at rest, opaque; leaving slid out one slide, transparent.
        let (enter, leave) = resolve_layers(g, 1.0, false, SIZE);
        assert_eq!(enter.dx, 0.0);
        assert_eq!(enter.alpha, 1.0);
        assert_eq!(leave.dx, -GLYPH_SLIDE_DP);
        assert_eq!(leave.alpha, 0.0);
    }

    #[test]
    fn glyph_pop_mirrors_push_direction() {
        // Back reverses direction: entering comes from
        // the left (negative offset), the popped page slides right.
        let (enter, _leave) = resolve_layers(PageTransition::Glyph, 0.0, true, SIZE);
        assert_eq!(enter.dx, -GLYPH_SLIDE_DP);
        let (_enter, leave) = resolve_layers(PageTransition::Glyph, 1.0, true, SIZE);
        assert_eq!(leave.dx, GLYPH_SLIDE_DP);
    }

    #[test]
    fn glyph_enter_exit_durations_unthemed_fallback() {
        // Unthemed fallback = the Glyph screen-transition authored values.
        let (enter, exit) = preset_enter_exit(PageTransition::Glyph, None);
        assert_eq!(enter, Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE));
        assert_eq!(exit, Timing::Duration(GLYPH_EXIT, GLYPH_EXIT_CURVE));
        let (Timing::Duration(de, _), Timing::Duration(dx, _)) = (enter, exit) else {
            panic!("expected duration timings");
        };
        assert_eq!(de, Duration::from_millis(340));
        assert_eq!(dx, Duration::from_millis(150));
        assert!(dx < de, "exits always faster than entrances");
    }

    #[test]
    fn glyph_enter_exit_durations_from_theme_scheme() {
        // With a scheme, enter/exit pull the slow/fast duration + spatial/exit
        // easing tokens.
        let m = MotionScheme::neutral();
        let (enter, exit) = preset_enter_exit(PageTransition::Glyph, Some(&m));
        assert_eq!(
            enter,
            Timing::Duration(
                Duration::from_secs_f64(m.durations.slow / 1000.0),
                m.easing.spatial
            )
        );
        assert_eq!(
            exit,
            Timing::Duration(
                Duration::from_secs_f64(m.durations.fast / 1000.0),
                m.easing.exit
            )
        );
    }

    #[test]
    fn theme_default_timing_resolves_to_enter_and_passes_explicit_through() {
        // `ThemeDefault` → the preset's enter timing (the driver's dominant motion).
        let resolved = resolve_timing(Timing::ThemeDefault, PageTransition::Glyph, None);
        assert_eq!(resolved, Timing::Duration(GLYPH_ENTER, GLYPH_SPATIAL_CURVE));
        // An explicit timing passes through unchanged.
        let explicit = Timing::Duration(Duration::from_millis(200), Curve::Linear);
        assert_eq!(
            resolve_timing(explicit, PageTransition::Glyph, None),
            explicit
        );
    }

    #[test]
    fn resolve_spec_resolves_theme_default_when_motion_enabled() {
        let m = MotionScheme::neutral(); // reduce_motion == false
        let resolved = resolve_spec(TransitionSpec::glyph(), Some(&m));
        assert_eq!(resolved.preset, PageTransition::Glyph);
        assert_eq!(
            resolved.timing,
            Timing::Duration(
                Duration::from_secs_f64(m.durations.slow / 1000.0),
                m.easing.spatial
            )
        );
    }

    #[test]
    fn reduce_motion_collapses_every_preset_to_crossfade() {
        // The Glyph design system's hard rule: reduced motion → ≤120ms linear
        // crossfade for every animated pattern.
        let mut m = MotionScheme::neutral();
        m.reduce_motion = true;
        for preset in [
            PageTransition::Glyph,
            PageTransition::M3SharedAxisX,
            PageTransition::IosPush,
            PageTransition::SlideUp,
            PageTransition::M3FadeThrough,
        ] {
            let resolved = resolve_spec(TransitionSpec::themed(preset), Some(&m));
            assert_eq!(
                resolved.preset,
                PageTransition::ReducedCrossfade,
                "{preset:?} must collapse to the pure alpha crossfade"
            );
            let Timing::Duration(d, curve) = resolved.timing else {
                panic!("reduced-motion must be a duration crossfade");
            };
            assert!(d <= Duration::from_millis(120), "{preset:?} not ≤120ms");
            assert_eq!(curve, Curve::Linear, "{preset:?}");
            // The collapse target must carry ZERO geometric motion at every
            // progress point — no slide, no scale (the scale-leak trap:
            // M3FadeThrough's 0.92→1.0 zoom must not survive into reduced
            // motion).
            for p in [0.0, 0.25, 0.5, 0.75, 1.0] {
                let (entering, leaving) =
                    resolve_layers(resolved.preset, p, false, Size::new(100.0, 100.0));
                for (label, l) in [("entering", entering), ("leaving", leaving)] {
                    assert_eq!((l.dx, l.dy), (0.0, 0.0), "{label} slid at p={p}");
                    assert_eq!(l.scale, 1.0, "{label} scaled at p={p}");
                }
            }
        }
        // A non-animated (`None`) spec is left untouched under reduce_motion.
        let none = resolve_spec(TransitionSpec::NONE, Some(&m));
        assert_eq!(none.preset, PageTransition::None);
    }

    // A distinctive custom preset used by the `Custom` tests below: a vertical
    // slide (dy only) with no cross-fade, so its output is trivially
    // distinguishable from every built-in preset's geometry.
    fn custom_vertical_slide(p: f64, is_pop: bool, size: Size) -> (Layer, Layer) {
        let dir = if is_pop { -1.0 } else { 1.0 };
        let entering = Layer {
            dx: 0.0,
            dy: dir * (1.0 - p) * size.height,
            alpha: 1.0,
            scale: 1.0,
        };
        let leaving = Layer::IDENTITY;
        (entering, leaving)
    }

    #[test]
    fn custom_preset_drives_caller_supplied_layers() {
        let (enter, leave) = resolve_layers(
            PageTransition::Custom(custom_vertical_slide),
            0.5,
            false,
            SIZE,
        );
        // The caller's geometry comes through unmodified — no clamping or
        // post-processing beyond what every preset gets.
        let (expected_enter, expected_leave) = custom_vertical_slide(0.5, false, SIZE);
        assert_eq!(enter, expected_enter);
        assert_eq!(leave, expected_leave);
        assert_eq!(enter.dy, 0.5 * SIZE.height);
        assert_eq!(leave, Layer::IDENTITY);

        // Raw (unclamped) progress passes through verbatim too — an overshoot
        // past 1.0 is visible in the caller's output, exactly like every
        // built-in preset's position calculation.
        let (enter, _leave) = resolve_layers(
            PageTransition::Custom(custom_vertical_slide),
            1.2,
            false,
            SIZE,
        );
        assert!((enter.dy - (-0.2 * SIZE.height)).abs() < 1e-9);
    }

    #[test]
    fn custom_preset_falls_back_to_m3_timing_under_theme_default() {
        // `Custom` has no theme tokens of its own: `preset_enter_exit` and
        // `resolve_timing` both fall back to the documented M3 default
        // (300ms + `Curve::Emphasized`) — the same fallback `make_driver` uses
        // for an unresolved `ThemeDefault` — regardless of whether a theme is
        // threaded.
        let expected = Timing::Duration(M3_DEFAULT_DURATION, Curve::Emphasized);

        let (enter, exit) = preset_enter_exit(PageTransition::Custom(custom_vertical_slide), None);
        assert_eq!(enter, expected);
        assert_eq!(exit, expected);

        let m = MotionScheme::neutral();
        let (enter, exit) =
            preset_enter_exit(PageTransition::Custom(custom_vertical_slide), Some(&m));
        assert_eq!(enter, expected);
        assert_eq!(exit, expected);

        let resolved = resolve_timing(
            Timing::ThemeDefault,
            PageTransition::Custom(custom_vertical_slide),
            Some(&m),
        );
        assert_eq!(resolved, expected);
    }

    #[test]
    fn custom_preset_collapses_under_reduce_motion_on_the_resolve_spec_path() {
        // This test covers only the `resolve_spec` seam — the path a
        // **programmatic** push/pop/replace resolves its timing through
        // (see `navigator.rs`'s `paint_transition`, the sole `resolve_spec`
        // call site). Under `reduce_motion`, `resolve_spec` collapses
        // `Custom` to `ReducedCrossfade` unchanged, so the supplied
        // function itself is never called by `resolve_spec`/`resolve_timing`
        // (only `resolve_layers` ever calls it, and this path only ever
        // calls `resolve_layers` with the *resolved* preset,
        // `ReducedCrossfade` here, not `Custom`).
        //
        // This is NOT a navigator-wide invariant: a user-driven interactive
        // edge-swipe pop does not go through `resolve_spec` at all and DOES
        // call the supplied function under `reduce_motion` — see
        // `navigator.rs`'s
        // `interactive_pop_calls_custom_fn_under_reduce_motion`, and
        // `Custom`'s doc comment above.
        fn panics_if_called(_p: f64, _is_pop: bool, _size: Size) -> (Layer, Layer) {
            panic!("Custom's function must not be invoked on the resolve_spec path");
        }

        let mut m = MotionScheme::neutral();
        m.reduce_motion = true;
        let resolved = resolve_spec(
            TransitionSpec::themed(PageTransition::Custom(panics_if_called)),
            Some(&m),
        );
        assert_eq!(resolved.preset, PageTransition::ReducedCrossfade);
        let Timing::Duration(d, curve) = resolved.timing else {
            panic!("reduced-motion must be a duration crossfade");
        };
        assert!(d <= Duration::from_millis(120));
        assert_eq!(curve, Curve::Linear);

        // Driving the *resolved* spec's layers never touches the caller's
        // function (it's no longer part of the resolved preset at all).
        let (entering, leaving) = resolve_layers(resolved.preset, 0.5, false, SIZE);
        assert_eq!((entering.dx, entering.dy), (0.0, 0.0));
        assert_eq!((leaving.dx, leaving.dy), (0.0, 0.0));
    }

    #[test]
    fn make_driver_theme_default_falls_back_when_unresolved() {
        // An unresolved `ThemeDefault` (no theme threaded) degrades to the M3
        // default duration; it still runs 0→1 like any duration driver.
        let (mut driver, _) = make_driver(Timing::ThemeDefault);
        let a = driver.advance(ft_secs(0.0));
        assert!(a.animating && !a.done);
        // Runs to completion past the M3 default duration (300ms).
        let a = driver.advance(ft_secs(1.0));
        assert!(a.done);
        assert_eq!(a.value, 1.0);
    }

    #[test]
    fn slide_up_enters_from_bottom_and_settles() {
        // At the start, the entering sheet sits a full height below rest, fully
        // visible (opacity is a page-owned scrim concern, not this preset's).
        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 0.0, false, SIZE);
        assert_eq!(enter.dx, 0.0);
        assert_eq!(enter.dy, SIZE.height);
        assert_eq!(enter.alpha, 1.0);
        // The page below never moves or fades.
        assert_eq!(leave.dx, 0.0);
        assert_eq!(leave.dy, 0.0);
        assert_eq!(leave.alpha, 1.0);

        // At the end, the sheet rests at dy = 0; the below page is unchanged.
        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 1.0, false, SIZE);
        assert_eq!(enter.dy, 0.0);
        assert_eq!(enter.alpha, 1.0);
        assert_eq!(leave.dy, 0.0);
        assert_eq!(leave.alpha, 1.0);
    }

    #[test]
    fn slide_up_pop_reverses_and_never_moves_below_page() {
        // Pop: the sheet (leaving) slides back down; the revealed page
        // (entering) stays static at rest throughout.
        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 0.0, true, SIZE);
        assert_eq!(enter.dy, 0.0);
        assert_eq!(leave.dy, 0.0);

        let (enter, leave) = resolve_layers(PageTransition::SlideUp, 1.0, true, SIZE);
        assert_eq!(enter.dy, 0.0, "revealed page never moves");
        assert_eq!(
            leave.dy, SIZE.height,
            "the sheet slides fully off the bottom"
        );
        assert_eq!(enter.alpha, 1.0);
        assert_eq!(leave.alpha, 1.0, "SlideUp never fades a page's own layer");
    }

    #[test]
    fn duration_driver_runs_zero_to_one_and_settles() {
        let (mut driver, _) =
            make_driver(Timing::Duration(Duration::from_millis(100), Curve::Linear));
        // Seed the clock (zero delta).
        let a = driver.advance(ft_secs(0.0));
        assert!(a.animating && !a.done);
        assert!((a.value - 0.0).abs() < 1e-9);
        // Halfway.
        let a = driver.advance(ft_secs(0.05));
        assert!((a.value - 0.5).abs() < 1e-6);
        // Past the end: settles at 1.0, reports done.
        let a = driver.advance(ft_secs(0.2));
        assert!(!a.animating && a.done);
        assert_eq!(a.value, 1.0);
    }

    #[test]
    fn spring_spatial_driver_overshoots_past_one() {
        // M3 default spatial: damping 0.9 — overshoots.
        let spring = MotionSpring {
            damping_ratio: 0.9,
            stiffness: 700.0,
        };
        let (mut driver, _) = make_driver(Timing::Spring(spring));
        let mut max = f64::MIN;
        let mut t = 0.0;
        for _ in 0..100_000 {
            let a = driver.advance(ft_secs(t));
            max = max.max(a.value);
            if a.done {
                break;
            }
            t += 1.0 / 120.0;
        }
        assert!(
            max > 1.0 + 1e-3,
            "spatial spring should overshoot past 1.0, got {max}"
        );
    }

    #[test]
    fn spring_effects_driver_never_overshoots() {
        // M3 default effects: damping 1.0 — critically damped, released from
        // rest, so it approaches 1.0 monotonically.
        let spring = MotionSpring {
            damping_ratio: 1.0,
            stiffness: 1600.0,
        };
        let (mut driver, _) = make_driver(Timing::Spring(spring));
        let mut t = 0.0;
        for _ in 0..100_000 {
            let a = driver.advance(ft_secs(t));
            assert!(
                a.value <= 1.0 + 1e-9,
                "effects spring overshot: {}",
                a.value
            );
            if a.done {
                break;
            }
            t += 1.0 / 120.0;
        }
    }

    #[test]
    fn held_driver_pauses_without_finishing() {
        let mut driver = TransitionDriver::Held { value: 0.4 };
        let a = driver.advance(ft_secs(1.0));
        assert_eq!(a.value, 0.4);
        assert!(!a.animating);
        assert!(!a.done, "a held driver never reports done (drag paused)");
    }

    #[test]
    fn lerp_rect_interpolates_origin_and_size_independently() {
        let from = Rect::from_origin_size(Point::new(0.0, 0.0), Size::new(20.0, 20.0));
        let to = Rect::from_origin_size(Point::new(100.0, 40.0), Size::new(200.0, 80.0));
        // Endpoints are exact.
        assert_eq!(lerp_rect(from, to, 0.0), from);
        assert_eq!(lerp_rect(from, to, 1.0), to);
        // Halfway: origin and size each land midway.
        let mid = lerp_rect(from, to, 0.5);
        assert_eq!(mid.origin(), Point::new(50.0, 20.0));
        assert_eq!(mid.size(), Size::new(110.0, 50.0));
    }

    #[test]
    fn rect_to_rect_maps_corners_exactly() {
        let from = Rect::from_origin_size(Point::new(10.0, 20.0), Size::new(20.0, 20.0));
        let to = Rect::from_origin_size(Point::new(100.0, 200.0), Size::new(80.0, 40.0));
        let t = rect_to_rect(from, to);
        let tl = t * Point::new(from.x0, from.y0);
        let br = t * Point::new(from.x1, from.y1);
        assert!((tl.x - to.x0).abs() < 1e-9 && (tl.y - to.y0).abs() < 1e-9);
        assert!((br.x - to.x1).abs() < 1e-9 && (br.y - to.y1).abs() < 1e-9);
    }

    #[test]
    fn rect_to_rect_zero_extent_source_is_identity_scale() {
        // A zero-width/height source must not divide by zero — it degenerates to
        // an identity scale on that axis (a pure translation of the origin).
        let from = Rect::from_origin_size(Point::new(5.0, 5.0), Size::new(0.0, 0.0));
        let to = Rect::from_origin_size(Point::new(9.0, 12.0), Size::new(0.0, 0.0));
        let t = rect_to_rect(from, to);
        let mapped = t * Point::new(5.0, 5.0);
        assert!((mapped.x - 9.0).abs() < 1e-9 && (mapped.y - 12.0).abs() < 1e-9);
        assert!(t.as_coeffs()[0].is_finite() && t.as_coeffs()[3].is_finite());
    }

    #[test]
    fn settle_driver_springs_to_target() {
        let mut driver = settle_driver(DEFAULT_SETTLE_SPRING, 0.6, 0.0, 1.0);
        let mut t = 0.0;
        let mut done = false;
        for _ in 0..100_000 {
            let a = driver.advance(ft_secs(t));
            if a.done {
                done = true;
                assert_eq!(a.value, 1.0);
                break;
            }
            t += 1.0 / 120.0;
        }
        assert!(done, "settle driver failed to reach its target");
    }
}