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