kui_core/enter.rs
1//! Entrance transitions: where a node's animatable slots start from the
2//! first frame it is seen. A transition never animates in from nowhere —
3//! a node's first sight snaps, so a view that wants a slide-in used to draw
4//! the node off screen for a frame and move it on the next. `NodeSpec::enter`
5//! states that starting point as data instead: on first sight the slots it
6//! names (`dx`/`dy` for the laid-out position, plus width, height, bg and
7//! radius in the forms the props themselves take) start there and ease to
8//! what the view declares, on the node's `transition`.
9//!
10//! `dx`/`dy` move the node's position in place, subtree and all, like
11//! `slide` does for reordered siblings; a node with `enter` but no `slide`
12//! eases only its entrance — a later layout move still snaps. Slots a
13//! `keyframes` stop names are sampled, not tweened, so `enter` leaves them
14//! alone. A node that leaves and comes back enters again (the core keeps no
15//! memory of a node it did not draw last frame, which is what a dismissed
16//! and re-shown toast wants).
17
18use crate::slots::{Slots, slot_builders};
19use crate::value::Value;
20
21/// Where a node's slots start on first sight. Every field is optional: a
22/// slot `enter` doesn't name simply snaps as it always did. Derefs to its
23/// [`Slots`], so `enter.bg` reads the slot.
24#[derive(Clone, Copy, Debug, Default, PartialEq)]
25pub struct Enter {
26 /// Position offset (logical px) the node eases in from — `dx: -300`
27 /// slides in from the left.
28 pub dx: f32,
29 pub dy: f32,
30 /// Width, height, bg, radius and opacity — the slots a keyframe stop
31 /// names too.
32 pub slots: Slots,
33}
34
35slot_builders!(Enter);
36
37impl Enter {
38 /// Slide in from `dx`/`dy` px away.
39 pub fn from(dx: f32, dy: f32) -> Self {
40 Self {
41 dx,
42 dy,
43 ..Self::default()
44 }
45 }
46
47 pub fn offset(mut self, dx: f32, dy: f32) -> Self {
48 self.dx = dx;
49 self.dy = dy;
50 self
51 }
52
53 /// Whether the entrance moves the node's position.
54 pub fn offsets(&self) -> bool {
55 self.dx != 0.0 || self.dy != 0.0
56 }
57}
58
59/// An entrance from plain data: a map with any of `dx`, `dy`, `width`,
60/// `height`, `bg`, `radius`, `opacity`, in the forms the props themselves take (the
61/// same shapes a keyframe stop accepts). Every binding funnels `enter`
62/// through here, so the shape is the same in JSX, Lua and C.
63pub fn parse(v: &Value) -> Result<Enter, String> {
64 parse_with(v, None)
65}
66
67/// [`parse`] with a token lookup, as `keyframes::parse_with`: a `$name`
68/// in `width`, `height`, `bg` or `radius` resolves, and a miss leaves the
69/// slot unnamed and is remembered on the refs (backlog AR14).
70pub fn parse_with(
71 v: &Value,
72 mut refs: Option<&mut crate::tokens::NameRefs<'_>>,
73) -> Result<Enter, String> {
74 let Value::Map(fields) = v else {
75 return Err("enter must be an object".into());
76 };
77 let mut e = Enter::default();
78 for (k, v) in fields {
79 let bad = |what: &str| format!("enter: {what}");
80 if e.slots
81 .parse_field(k, v, refs.as_deref_mut())
82 .map_err(|e| bad(&e))?
83 {
84 continue;
85 }
86 let num = |what: &str| {
87 v.as_float()
88 .map(|n| n as f32)
89 .ok_or_else(|| bad(&format!("{what} must be a number")))
90 };
91 match k.as_str() {
92 "dx" => e.dx = num("dx")?,
93 "dy" => e.dy = num("dy")?,
94 other => return Err(bad(&format!("unknown field {other:?}"))),
95 }
96 }
97 Ok(e)
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::color::Color;
104 use crate::spec::Sizing;
105
106 #[test]
107 fn parses_prop_shaped_values() {
108 let e = parse(&Value::map([
109 ("dx", Value::Int(-40)),
110 ("dy", Value::Float(2.5)),
111 ("width", Value::map([("grow", Value::Int(0))])),
112 ("height", Value::str("50%")),
113 ("bg", Value::str("#ff000000")),
114 ("radius", Value::Int(3)),
115 ("opacity", Value::Float(0.0)),
116 ]))
117 .unwrap();
118 assert_eq!(
119 e,
120 Enter::from(-40.0, 2.5)
121 .width(Sizing::Grow(0.0))
122 .height(Sizing::Percent(0.5))
123 .bg(Color::hex(0xff000000))
124 .radius(3.0)
125 .opacity(0.0)
126 );
127 assert!(e.offsets());
128 assert!(!Enter::default().bg(Color::WHITE).offsets());
129 }
130
131 #[test]
132 fn rejects_bad_shapes() {
133 let bad = |v: Value| parse(&v).unwrap_err();
134 assert!(bad(Value::List(vec![])).contains("object"));
135 assert!(bad(Value::map([("dx", Value::str("far"))])).contains("dx must be a number"));
136 assert!(bad(Value::map([("colour", Value::str("#fff"))])).contains("unknown field"));
137 assert!(bad(Value::map([("width", Value::str("wide"))])).contains("bad sizing"));
138 }
139}