Skip to main content

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}