The widget half of the widget catalogue's later work, ported onto makepad-widgets-core and the family crates. - libs/tween: a dependency-free tween and timeline engine -- nested timelines with positions and labels, stagger, repeat and yoyo, seek and reverse, time scale, a ticker with lag smoothing, a global pause and reduced motion, colour-space interpolation, keyframes and motion paths. The core binds it to the widget tree (tween, a widget's TweenHost), gives script a mod.tween (tween_script) and a live inspector (tween_inspect, tween_inspector). The Animator's Ease now answers from the engine's easing, so the curves have one source; the Animator keeps its states, its keyframe timelines and its own clock. - libs/gizmo and a Gizmo3d / ViewCube widget. - core: the ease editor, the sequencer, diagonal headings, hotkeys, the hotkey editor and keyboard map, the overlay layer host, a conceding row and the width a widget would take without drawing; a theme mix and a person's own theme edits that survive a style reload (theme_mix, set_theme_edit), both taken off by set_base_theme. - the surface material: buttons, check boxes, toggles, sliders and knobs carry the moulded material behind one uniform, a press idiom per sign of material_press, and four sheets that raise it: neumorphic, molded, glossy and milled. - the sheets: a row of fields is one height under every sheet. A sheet line that names a widget the app did not register (a family left out) is skipped when the sheet installs, so the date fields a sheet sizes need the dates family only where it is linked. - extras: the gradient editor, registered with the colour picker it builds on. - tweaker: the designer (a widget's literal located and edited in its source as hunks), the theme lab and the theme builder. The window reads the docked panel's band through the tweaker's hooks. - the panel no longer wears the app's theme (the design surface keeps its own palette), so the worn-theme tests go with it. Five theme builder tests fail here as they do on the branch this comes from (every_built_theme_reads and four more on text contrast); radial_menu's tab_under_an_open_menu_finds_nowhere_to_go fails here as it does on dev.
453 lines
14 KiB
Rust
453 lines
14 KiB
Rust
//! Groups (design 12.7 engine part and 12.8): keyframes and staggers built
|
|
//! as groups, replayed against the GSAP 3.15 sample and stagger goldens.
|
|
|
|
mod util;
|
|
|
|
#[path = "golden"]
|
|
mod golden {
|
|
pub mod common;
|
|
pub mod samples;
|
|
pub mod stagger_gsap;
|
|
}
|
|
|
|
use golden::common::{num, Series};
|
|
use golden::samples::{SampleCase, CASES};
|
|
use golden::stagger_gsap::{DistConfig, From, DISTRIBUTE, IN_TWEEN};
|
|
use makepad_tween::*;
|
|
use util::*;
|
|
|
|
fn sample_case(id: &str) -> &'static SampleCase {
|
|
CASES
|
|
.iter()
|
|
.find(|c| c.id == id)
|
|
.unwrap_or_else(|| panic!("no sample case {id}"))
|
|
}
|
|
|
|
fn read(e: &TweenEngine, a: TweenId, name: &str) -> f64 {
|
|
match name {
|
|
"x" => val(e, 0, X),
|
|
"y" => val(e, 0, Y),
|
|
"anim.time" => e.anim_ref(a).time(),
|
|
"anim.iteration" => e.anim_ref(a).iteration() as f64,
|
|
other => {
|
|
let (o, p) = other.split_once('.').unwrap();
|
|
let i: u32 = o[1..].parse().unwrap();
|
|
val(e, i, if p == "x" { X } else { Y })
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drives `a` with `totalTime(t)` over the case's grid and compares every
|
|
/// series (times 1e-9, values 5e-7).
|
|
fn check(e: &mut TweenEngine, a: TweenId, c: &SampleCase) {
|
|
check_tol(e, a, c, VAL_TOL);
|
|
}
|
|
|
|
/// [`check`] with a value tolerance.
|
|
fn check_tol(e: &mut TweenEngine, a: TweenId, c: &SampleCase, val_tol: f64) {
|
|
let series: &[Series] = c.series;
|
|
for (i, t) in c.t.iter().enumerate() {
|
|
e.anim(a).set_total_time(*t, Emit::Fire);
|
|
for s in series {
|
|
let tol = if s.name.starts_with("anim") {
|
|
TIME_TOL
|
|
} else {
|
|
val_tol
|
|
};
|
|
close(
|
|
read(e, a, s.name),
|
|
s.v[i],
|
|
tol,
|
|
&format!("{}: {} at {t}", c.id, s.name),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn fresh(x: f64) -> TweenEngine {
|
|
let mut e = TweenEngine::new();
|
|
e.seed(tg(0), X, TweenValue::F64(x));
|
|
e.seed(tg(0), Y, TweenValue::F64(0.0));
|
|
e
|
|
}
|
|
|
|
fn step(props: &'static [PropTo<'static>], o: TweenOpts) -> KeyStep<'static> {
|
|
KeyStep { props, opts: o }
|
|
}
|
|
|
|
fn key(at: f64, v: f64) -> Key {
|
|
Key {
|
|
at,
|
|
value: TweenValue::F64(v),
|
|
ease: None,
|
|
}
|
|
}
|
|
|
|
static X50: [PropTo; 1] = [PropTo::to_f64(X, 50.0)];
|
|
static X20: [PropTo; 1] = [PropTo::to_f64(X, 20.0)];
|
|
static X100: [PropTo; 1] = [PropTo::to_f64(X, 100.0)];
|
|
static X0: [PropTo; 1] = [PropTo::to_f64(X, 0.0)];
|
|
static Y50: [PropTo; 1] = [PropTo::to_f64(Y, 50.0)];
|
|
|
|
#[test]
|
|
fn golden_array_keyframes() {
|
|
for (id, outer) in [
|
|
("keyframes_array", None),
|
|
("keyframes_array_outer_ease", Some(Easing::InOutCubic)),
|
|
] {
|
|
let c = sample_case(id);
|
|
let mut e = fresh(0.0);
|
|
let steps = [
|
|
step(&X50, TweenOpts::new().duration(0.5)),
|
|
step(&X20, TweenOpts::new().duration(1.0).ease(Easing::InQuad)),
|
|
];
|
|
let mut o = TweenOpts::new().paused(true);
|
|
if let Some(ea) = outer {
|
|
o = o.ease(ea);
|
|
}
|
|
let g = e.keyframes(one(0), &steps, o);
|
|
assert_eq!(
|
|
val(&e, 0, X),
|
|
num(c.meta, "immediate_after_create.x"),
|
|
"{id}"
|
|
);
|
|
assert_eq!(e.anim_ref(g).duration(), num(c.meta, "duration"), "{id}");
|
|
check(&mut e, g, c);
|
|
}
|
|
// Steps without durations take the default 0.5 each.
|
|
let c = sample_case("design_kf_array_default_durations");
|
|
let mut e = fresh(0.0);
|
|
let g = e.keyframes(
|
|
one(0),
|
|
&[step(&X100, TweenOpts::new()), step(&X0, TweenOpts::new())],
|
|
TweenOpts::new().paused(true),
|
|
);
|
|
assert_eq!(e.anim_ref(g).duration(), num(c.meta, "duration"));
|
|
check(&mut e, g, c);
|
|
// [{x: 100, d 1}, {y: 50, d .5}]
|
|
let c = sample_case("design_kf_steps_x_then_y");
|
|
let mut e = fresh(0.0);
|
|
let g = e.keyframes(
|
|
one(0),
|
|
&[
|
|
step(&X100, TweenOpts::new().duration(1.0)),
|
|
step(&Y50, TweenOpts::new().duration(0.5)),
|
|
],
|
|
TweenOpts::new().paused(true),
|
|
);
|
|
assert_eq!(e.anim_ref(g).duration(), num(c.meta, "duration"));
|
|
check(&mut e, g, c);
|
|
}
|
|
|
|
#[test]
|
|
fn golden_percent_keyframes() {
|
|
let keys = [key(0.0, 0.0), key(50.0, 80.0), key(100.0, 100.0)];
|
|
for (id, each, outer) in [
|
|
(
|
|
"keyframes_percent_easeEach_none",
|
|
Some(Easing::Linear),
|
|
None,
|
|
),
|
|
("keyframes_percent_default_easeEach", None, None),
|
|
(
|
|
"keyframes_percent_outer_ease",
|
|
Some(Easing::Linear),
|
|
Some(Easing::InCubic),
|
|
),
|
|
] {
|
|
let c = sample_case(id);
|
|
let mut e = fresh(5.0);
|
|
let mut o = TweenOpts::new().duration(2.0).paused(true);
|
|
if let Some(ea) = each {
|
|
o = o.ease_each(ea);
|
|
}
|
|
if let Some(ea) = outer {
|
|
o = o.ease(ea);
|
|
}
|
|
let g = e.to(one(0), &[PropTo::keys(X, &keys)], o);
|
|
assert_eq!(
|
|
val(&e, 0, X),
|
|
num(c.meta, "immediate_after_create.x"),
|
|
"{id}: no immediate render"
|
|
);
|
|
// With an outer ease the inner time goes through round7; GSAP's
|
|
// power2.in is `p ** 3` (V8 pow), the parity InCubic is `p * p * p`:
|
|
// a last-bit difference on a round7 tie moves the inner time by
|
|
// 1e-7 s (8e-6 in x) at a few points.
|
|
let tol = if outer.is_some() { 1e-5 } else { VAL_TOL };
|
|
check_tol(&mut e, g, c, tol);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn percent_and_value_keyframes() {
|
|
// {0%: 0, 50%: 100}, duration 1: padded to 100% [golden: design_kf_percent_0_50_warm].
|
|
let keys = [key(0.0, 0.0), key(50.0, 100.0)];
|
|
let c = sample_case("design_kf_percent_0_50_warm");
|
|
let mut e = fresh(0.0);
|
|
let g = e.to(
|
|
one(0),
|
|
&[PropTo::keys(X, &keys)],
|
|
TweenOpts::new().duration(1.0).paused(true),
|
|
);
|
|
e.anim(g).set_total_time(1.0, Emit::Fire);
|
|
e.anim(g).set_total_time(0.0, Emit::Fire);
|
|
check(&mut e, g, c);
|
|
// Values [0, 100, 50] at 0/50/100% [golden: design_kf_values_0_100_50].
|
|
let vals = [
|
|
TweenValue::F64(0.0),
|
|
TweenValue::F64(100.0),
|
|
TweenValue::F64(50.0),
|
|
];
|
|
let c = sample_case("design_kf_values_0_100_50");
|
|
let mut e = fresh(0.0);
|
|
let g = e.to(
|
|
one(0),
|
|
&[PropTo::values(X, &vals)],
|
|
TweenOpts::new().duration(1.0).paused(true),
|
|
);
|
|
assert_eq!(e.anim_ref(g).duration(), num(c.meta, "duration"));
|
|
check(&mut e, g, c);
|
|
}
|
|
|
|
#[test]
|
|
fn padded_percent_keyframes_first_frame_deviate() {
|
|
// GSAP's first render of a padded percent-keyframes tween uses a stale
|
|
// inner duration (x(0.125) = 3.125); the engine uses the padded one.
|
|
let c = sample_case("design_kf_percent_0_50_fresh");
|
|
assert_eq!(c.series[0].v[0], 3.125);
|
|
let keys = [key(0.0, 0.0), key(50.0, 100.0)];
|
|
let mut e = fresh(0.0);
|
|
let g = e.to(
|
|
one(0),
|
|
&[PropTo::keys(X, &keys)],
|
|
TweenOpts::new().duration(1.0).paused(true),
|
|
);
|
|
let want = [12.5, 50.0, 100.0];
|
|
for (t, w) in c.t.iter().zip(want) {
|
|
e.anim(g).set_total_time(*t, Emit::Fire);
|
|
close(val(&e, 0, X), w, 1e-9, &format!("t {t}"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn golden_plain_props_next_to_keyframes_are_linear() {
|
|
let keys = [key(50.0, 100.0), key(100.0, 100.0)];
|
|
for (id, outer) in [
|
|
("design_kf_ordinary_prop", None),
|
|
("design_kf_ordinary_prop_outer_ease", Some(Easing::InCubic)),
|
|
] {
|
|
let c = sample_case(id);
|
|
let mut e = fresh(0.0);
|
|
let mut o = TweenOpts::new().duration(1.0).paused(true);
|
|
if let Some(ea) = outer {
|
|
o = o.ease(ea);
|
|
}
|
|
let g = e.to(
|
|
one(0),
|
|
&[PropTo::keys(X, &keys), PropTo::to_f64(Y, 50.0)],
|
|
o,
|
|
);
|
|
check(&mut e, g, c);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn outer_ease_defaults_to_linear_and_ease_each_to_in_out_quad() {
|
|
let keys = [key(100.0, 100.0)];
|
|
let mut e = fresh(0.0);
|
|
let g = e.to(
|
|
one(0),
|
|
&[PropTo::keys(X, &keys)],
|
|
TweenOpts::new().duration(1.0).paused(true),
|
|
);
|
|
e.anim(g).set_total_time(0.25, Emit::Fire);
|
|
close(
|
|
val(&e, 0, X),
|
|
100.0 * Easing::InOutQuad.map(0.25),
|
|
1e-12,
|
|
"ease_each",
|
|
);
|
|
// The engine default ease (OutQuad) does not reach keyframes.
|
|
let mut e = fresh(0.0);
|
|
let g = e.keyframes(
|
|
one(0),
|
|
&[step(&X100, TweenOpts::new().duration(1.0))],
|
|
TweenOpts::new().paused(true),
|
|
);
|
|
e.anim(g).set_total_time(0.25, Emit::Fire);
|
|
close(val(&e, 0, X), 25.0, 1e-12, "array steps are linear");
|
|
}
|
|
|
|
#[test]
|
|
fn golden_stagger_samples_match_gsap() {
|
|
for (id, ea) in [
|
|
("stagger_5_each02_none", Easing::Linear),
|
|
("stagger_5_each02_power2in", Easing::InCubic),
|
|
] {
|
|
let c = sample_case(id);
|
|
let mut e = TweenEngine::new();
|
|
seed_all(&mut e, 5, X, 0.0);
|
|
seed_all(&mut e, 5, Y, 0.0);
|
|
let g = e.to(
|
|
Targets::Range { first: 0, count: 5 },
|
|
&[to(X, 100.0), to(Y, 50.0)],
|
|
TweenOpts::new()
|
|
.duration(1.0)
|
|
.ease(ea)
|
|
.stagger(Stagger::each(0.2))
|
|
.paused(true),
|
|
);
|
|
assert_eq!(e.anim_ref(g).duration(), num(c.meta, "duration"), "{id}");
|
|
assert_eq!(e.anim_ref(g).child_count(), 5);
|
|
check(&mut e, g, c);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn golden_group_yoyo_ease_reaches_children() {
|
|
let c = sample_case("design_stagger_yoyo_ease");
|
|
let mut e = TweenEngine::new();
|
|
seed_all(&mut e, 3, X, 0.0);
|
|
let g = e.to(
|
|
Targets::Range { first: 0, count: 3 },
|
|
&[to(X, 100.0)],
|
|
TweenOpts::new()
|
|
.duration(1.0)
|
|
.ease(Easing::Linear)
|
|
.stagger(Stagger::each(0.2))
|
|
.repeat(1)
|
|
.yoyo(true)
|
|
.yoyo_ease(YoyoEase::Ease(Easing::OutQuad))
|
|
.paused(true),
|
|
);
|
|
assert_eq!(e.anim_ref(g).duration(), num(c.meta, "duration"));
|
|
assert_eq!(e.anim_ref(g).total_duration(), num(c.meta, "totalDuration"));
|
|
check(&mut e, g, c);
|
|
}
|
|
|
|
fn to_stagger(d: &DistConfig) -> (Stagger, Option<Easing>) {
|
|
let mut s = match (d.each, d.amount) {
|
|
(_, Some(a)) => Stagger::amount(a),
|
|
(Some(e), None) => Stagger::each(e),
|
|
(None, None) => Stagger::each(0.0),
|
|
};
|
|
s = s.from(match d.from {
|
|
From::Index(v) if v.fract() == 0.0 && (v == 0.0 || v >= 1.0) => {
|
|
StaggerFrom::Index(v as u32)
|
|
}
|
|
From::Index(v) | From::Ratio(v) => StaggerFrom::Ratio(v, v),
|
|
From::Ratio2(x, y) => StaggerFrom::Ratio(x, y),
|
|
From::Named("center") => StaggerFrom::Center,
|
|
From::Named("edges") => StaggerFrom::Edges,
|
|
From::Named("end") => StaggerFrom::End,
|
|
From::Named("start") => StaggerFrom::Start,
|
|
From::Named(other) => panic!("from {other}"),
|
|
});
|
|
if let Some([rows, cols]) = d.grid {
|
|
s = s.grid(rows, cols);
|
|
}
|
|
if let Some(a) = d.axis {
|
|
s = s.axis(if a == "x" {
|
|
StaggerAxis::X
|
|
} else {
|
|
StaggerAxis::Y
|
|
});
|
|
}
|
|
if let Some(b) = d.base {
|
|
s = s.base(b);
|
|
}
|
|
if let Some(r) = d.repeat {
|
|
s = s.each_repeat(r as i32);
|
|
}
|
|
let ease = d.ease.map(|n| parse_gsap_ease(n).unwrap());
|
|
if let Some(ea) = ease {
|
|
s = s.ease(ea);
|
|
}
|
|
(s, ease)
|
|
}
|
|
|
|
#[test]
|
|
fn golden_staggered_group_duration() {
|
|
for c in IN_TWEEN {
|
|
let (st, _) = to_stagger(&c.stagger);
|
|
let mut e = TweenEngine::new();
|
|
seed_all(&mut e, c.n, X, 0.0);
|
|
let mut o = lin(1.0).stagger(st).paused(true);
|
|
if let Some(r) = c.tween_repeat {
|
|
o = o.repeat(r as i32);
|
|
}
|
|
let g = e.to(
|
|
Targets::Range {
|
|
first: 0,
|
|
count: c.n,
|
|
},
|
|
&[to(X, 1.0)],
|
|
o,
|
|
);
|
|
let r = e.anim_ref(g);
|
|
close(r.duration(), c.tween_duration, TIME_TOL, c.id);
|
|
close(r.total_duration(), c.tween_total_duration, TIME_TOL, c.id);
|
|
// Each child starts at its distribute delay: target i is at x = 0
|
|
// until the group reaches that delay.
|
|
for (i, d) in c.child_start_times_by_target_index.iter().enumerate() {
|
|
e.anim(g).set_total_time(d + 0.05, Emit::Suppress);
|
|
close(
|
|
val(&e, i as u32, X),
|
|
0.05,
|
|
1e-9,
|
|
&format!("{} child {i}", c.id),
|
|
);
|
|
if *d > 0.0 {
|
|
e.anim(g).set_total_time(0.0, Emit::Suppress);
|
|
e.anim(g).set_total_time(d - 0.01, Emit::Suppress);
|
|
assert_eq!(val(&e, i as u32, X), 0.0, "{} child {i} not started", c.id);
|
|
}
|
|
e.anim(g).set_total_time(0.0, Emit::Suppress);
|
|
}
|
|
}
|
|
// Design values: 5 targets, each .1, duration 1 -> 1.4; group repeat 1
|
|
// -> 2.8; stagger each_repeat 1 -> 2.4.
|
|
let mut e = TweenEngine::new();
|
|
let t5 = Targets::Range { first: 0, count: 5 };
|
|
let a = e.to(
|
|
t5,
|
|
&[to(X, 1.0)],
|
|
lin(1.0).stagger(Stagger::each(0.1)).paused(true),
|
|
);
|
|
let b = e.to(
|
|
t5,
|
|
&[to(X, 1.0)],
|
|
lin(1.0).stagger(Stagger::each(0.1)).repeat(1).paused(true),
|
|
);
|
|
let c = e.to(
|
|
t5,
|
|
&[to(X, 1.0)],
|
|
lin(1.0)
|
|
.stagger(Stagger::each(0.1).each_repeat(1))
|
|
.paused(true),
|
|
);
|
|
assert_eq!(e.anim_ref(a).duration(), 1.4);
|
|
assert_eq!(e.anim_ref(b).total_duration(), 2.8);
|
|
assert_eq!(e.anim_ref(c).total_duration(), 2.4);
|
|
}
|
|
|
|
#[test]
|
|
fn golden_distribute_cases() {
|
|
// Every GSAP distribute() case (the reconciled superset) through
|
|
// Stagger::delays, within 1e-12 (the 1-D fast path skips round7).
|
|
for c in DISTRIBUTE {
|
|
let (st, _) = to_stagger(&c.config);
|
|
let mut out = vec![0.0; c.n as usize];
|
|
st.delays(c.n, &mut out);
|
|
for (i, (got, want)) in out.iter().zip(c.delays).enumerate() {
|
|
close(
|
|
*got,
|
|
*want,
|
|
1e-12,
|
|
&format!("{} [{i}] {}", c.id, c.config_json),
|
|
);
|
|
assert_eq!(st.delay(i as u32, c.n).to_bits(), got.to_bits(), "{}", c.id);
|
|
}
|
|
}
|
|
}
|