Skip to main content

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}