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