aetna_core/state/animation.rs
1//! Visual animation, caret blink, and state-summary helpers.
2
3use std::collections::HashSet;
4
5use web_time::Instant;
6
7use crate::anim::AnimProp;
8use crate::anim::tick::{HotTargets, is_in_flight, tick_node};
9use crate::event::UiTarget;
10use crate::tree::El;
11
12use super::{AnimationMode, EnvelopeKind, UiState, caret_blink_alpha_for};
13
14impl UiState {
15 /// Current eased state envelope amount in `[0, 1]` for `(id, kind)`.
16 /// Missing entries read as `0.0`.
17 pub fn envelope(&self, id: &str, kind: EnvelopeKind) -> f32 {
18 self.animation
19 .envelopes
20 .get(&(id.to_string(), kind))
21 .copied()
22 .unwrap_or(0.0)
23 }
24
25 /// Reset the caret-blink phase to "fully on": the painter holds
26 /// the caret solid for `CARET_BLINK_GRACE` after this call before
27 /// resuming the on/off cycle. Called whenever the user does
28 /// something the caret should react to — focusing an input,
29 /// moving the caret, replacing the selection.
30 pub(crate) fn bump_caret_activity(&mut self, now: Instant) {
31 self.caret.activity_at = Some(now);
32 self.caret.blink_alpha = 1.0;
33 }
34
35 /// Walk the laid-out tree, retarget per-(node, prop) animations to
36 /// the values implied by each node's current state, step them
37 /// forward to `now`, and write back: app-driven props mutate the
38 /// El's `fill` / `text_color` / `stroke` / `opacity` / `translate` /
39 /// `scale` (so the next rebuild reads the eased value); state
40 /// envelopes are written to the envelope side map for `draw_ops` to
41 /// modulate visuals from.
42 ///
43 /// Returns `true` if any animation is still in flight; the host
44 /// should request another redraw next frame.
45 pub fn tick_visual_animations(&mut self, root: &mut El, now: Instant) -> bool {
46 let mut visited: HashSet<(String, AnimProp)> = HashSet::new();
47 let mut needs_redraw = false;
48 let mode = self.animation.mode;
49 // Snapshot the leaf hover/focus/press targets so the per-node
50 // tick can derive subtree-membership without re-borrowing self.
51 let hot = HotTargets {
52 hovered: self.hovered.as_ref().map(|t| t.node_id.as_str()),
53 focused: self.focused.as_ref().map(|t| t.node_id.as_str()),
54 pressed: self.pressed.as_ref().map(|t| t.node_id.as_str()),
55 };
56 tick_node(
57 root,
58 &mut self.animation.animations,
59 &mut self.animation.envelopes,
60 &self.node_states.nodes,
61 hot,
62 self.focus_visible,
63 &mut visited,
64 now,
65 mode,
66 &mut needs_redraw,
67 );
68 // GC: drop animations whose node left the tree this frame.
69 self.animation
70 .animations
71 .retain(|key, _| visited.contains(key));
72 // Build a set of live node ids once — used by both envelope and
73 // widget_state GC. Cheaper than the previous per-entry linear
74 // scan over `visited`, which now matters because widget_state
75 // entries can outnumber envelopes.
76 let live_ids: HashSet<&str> = visited.iter().map(|(id, _)| id.as_str()).collect();
77 self.animation
78 .envelopes
79 .retain(|(id, _), _| live_ids.contains(id.as_str()));
80 self.widget_states
81 .entries
82 .retain(|(id, _), _| live_ids.contains(id.as_str()));
83
84 // Caret blink. Resolve the new alpha from the activity age,
85 // then keep requesting redraws as long as a capture_keys node
86 // is focused so the cycle keeps animating in idle frames.
87 // `Settled` mode pins the caret to fully on so headless
88 // single-frame snapshots don't randomly catch the off phase.
89 if let Some(activity_at) = self.caret.activity_at {
90 let alpha = match mode {
91 AnimationMode::Settled => 1.0,
92 AnimationMode::Live => {
93 caret_blink_alpha_for(now.saturating_duration_since(activity_at))
94 }
95 };
96 self.caret.blink_alpha = alpha;
97 }
98 if mode == AnimationMode::Live && self.focused_node_captures_keys(root) {
99 needs_redraw = true;
100 }
101
102 needs_redraw
103 }
104
105 /// Walk `root` and return whether the currently-focused node has
106 /// `capture_keys` set. Used by the animation tick to keep
107 /// requesting redraws while a text input is focused (so the caret
108 /// blink keeps animating). Returns `false` when no node is focused
109 /// or the focused node isn't in the tree.
110 fn focused_node_captures_keys(&self, root: &El) -> bool {
111 let Some(focused) = self.focused.as_ref() else {
112 return false;
113 };
114 crate::runtime::find_capture_keys(root, &focused.node_id).unwrap_or(false)
115 }
116
117 /// Switch animation pacing. The default is [`AnimationMode::Live`];
118 /// headless render binaries flip to [`AnimationMode::Settled`] so
119 /// a single-frame snapshot reflects the post-animation visual
120 /// without depending on integrator timing.
121 pub fn set_animation_mode(&mut self, mode: AnimationMode) {
122 self.animation.mode = mode;
123 }
124
125 /// Current animation pacing. Backends read this to gate
126 /// time-driven shader uniforms (e.g. `frame.time`) so headless
127 /// fixtures stay byte-identical regardless of when they ran.
128 pub fn animation_mode(&self) -> AnimationMode {
129 self.animation.mode
130 }
131
132 /// Whether any visual animation is still moving. The host's runner
133 /// uses this (via the renderer's `PrepareResult`) to keep the redraw
134 /// loop ticking only while there's motion.
135 pub fn has_animations_in_flight(&self) -> bool {
136 self.animation.animations.values().any(is_in_flight)
137 }
138
139 /// One-line summary of interactive state for diagnostic logging.
140 /// Format: `hov=<key|->|press=<key|->|focus=<key|->|env={...}|in_flight=N`.
141 /// Keep terse — this is intended for per-frame `console.log`.
142 pub fn debug_summary(&self) -> String {
143 let key = |t: &Option<UiTarget>| {
144 t.as_ref()
145 .map(|t| t.key.clone())
146 .unwrap_or_else(|| "-".into())
147 };
148 let mut env: Vec<String> = self
149 .animation
150 .envelopes
151 .iter()
152 .map(|((id, kind), v)| format!("{id}/{kind:?}={v:.3}"))
153 .collect();
154 env.sort();
155 let in_flight = self
156 .animation
157 .animations
158 .values()
159 .filter(|a| is_in_flight(a))
160 .count();
161 format!(
162 "hov={}|press={}|focus={}|env=[{}]|in_flight={}/{}",
163 key(&self.hovered),
164 key(&self.pressed),
165 key(&self.focused),
166 env.join(","),
167 in_flight,
168 self.animation.animations.len(),
169 )
170 }
171}