Skip to main content

euv_core/reactive/transition/
impl.rs

1//! `App::use_transition` and the matching
2//! `HookContext::transition` factory. Same pattern as the
3//! profiler / form / i18n factories.
4
5use super::*;
6
7impl HookContextTransitionExt for HookContext {
8    fn transition(config: TransitionConfig) -> TransitionState {
9        let hook_context: HookContext = Self::current();
10        let Ok(mut inner) = hook_context.get_inner().try_borrow_mut() else {
11            return TransitionState::new(
12                Signal::create(TransitionPhase::Exited),
13                Signal::create(0.0_f64),
14                Signal::create(config),
15            );
16        };
17        let index: usize = inner.get_hook_index();
18        inner.set_hook_index(index + 1);
19        if index < inner.get_hooks().len()
20            && let Some(existing) = inner.get_hooks()[index].downcast_ref::<TransitionState>()
21        {
22            // Slot already has a state — refresh its
23            // config but leave the existing phase /
24            // progress intact. (Callers that want to
25            // reset explicitly should call
26            // `TransitionState::reset`.)
27            existing.change_config(config);
28            return existing.clone();
29        }
30        let state: TransitionState = TransitionState::new(
31            Signal::create(TransitionPhase::Exited),
32            Signal::create(0.0_f64),
33            Signal::create(config),
34        );
35        if index < inner.get_hooks().len() {
36            inner.get_mut_hooks()[index] = Box::new(state.clone());
37        } else {
38            inner.get_mut_hooks().push(Box::new(state.clone()));
39        }
40        state
41    }
42}
43
44impl TransitionPhase {
45    /// Returns a fresh `TransitionPhase` value of
46    /// `Exited`. Convenience for `Signal::create` call
47    /// sites.
48    pub const fn exited() -> Self {
49        TransitionPhase::Exited
50    }
51}
52
53impl TransitionConfig {
54    /// Returns a config with both enter and exit durations
55    /// set to `ms`.
56    ///
57    /// Named `with_ms` (not `new`) to avoid colliding
58    /// with the `new` constructor generated by
59    /// `#[derive(New)]`. (We don't derive `New` here
60    /// because the field name `enter_ms` would generate
61    /// a `set_enter_ms` setter that nobody asked for —
62    /// keeping the struct `Copy` and writing it via
63    /// `TransitionConfig { enter_ms, exit_ms }` is
64    /// simpler.)
65    pub const fn with_ms(ms: u32) -> Self {
66        Self {
67            enter_ms: ms,
68            exit_ms: ms,
69        }
70    }
71
72    /// Returns a config with separate enter and exit
73    /// durations. See `with_ms` for the naming note.
74    pub const fn with_durations(enter_ms: u32, exit_ms: u32) -> Self {
75        Self { enter_ms, exit_ms }
76    }
77
78    /// Returns the duration (in ms) corresponding to the
79    /// given phase. Returns `0` for terminal phases
80    /// (`Entered`, `Exited`) — these don't tick.
81    pub fn duration_for(&self, phase: TransitionPhase) -> u32 {
82        match phase {
83            TransitionPhase::Entering => self.enter_ms,
84            TransitionPhase::Exiting => self.exit_ms,
85            TransitionPhase::Entered | TransitionPhase::Exited => 0,
86        }
87    }
88}
89
90impl Default for TransitionConfig {
91    fn default() -> Self {
92        // Matches the CSS defaults used elsewhere in
93        // euv-ui (var!(duration-normal) ≈ 200ms).
94        Self::with_ms(200)
95    }
96}
97
98impl TransitionState {
99    /// Returns a `Signal<TransitionPhase>` clone of the
100    /// current phase.
101    pub fn phase(&self) -> Signal<TransitionPhase> {
102        self.get_phase().clone()
103    }
104
105    /// Returns a `Signal<f64>` clone of the current
106    /// progress.
107    pub fn progress(&self) -> Signal<f64> {
108        self.get_progress().clone()
109    }
110
111    /// Returns a `Signal<TransitionConfig>` clone of the
112    /// current config.
113    pub fn config(&self) -> Signal<TransitionConfig> {
114        self.get_config().clone()
115    }
116
117    /// Returns the current phase as a snapshot value.
118    pub fn current_phase(&self) -> TransitionPhase {
119        self.get_phase().get()
120    }
121
122    /// Returns the current progress as a snapshot value.
123    pub fn current_progress(&self) -> f64 {
124        self.get_progress().get()
125    }
126
127    /// Returns the current config as a snapshot value.
128    pub fn current_config(&self) -> TransitionConfig {
129        self.get_config().get()
130    }
131
132    /// Returns `true` if the element is currently
133    /// animating (i.e. `Entering` or `Exiting`).
134    pub fn is_animating(&self) -> bool {
135        matches!(
136            self.get_phase().get(),
137            TransitionPhase::Entering | TransitionPhase::Exiting
138        )
139    }
140
141    /// Returns `true` if the element is fully on-screen
142    /// (`Entered`).
143    pub fn is_entered(&self) -> bool {
144        self.get_phase().get() == TransitionPhase::Entered
145    }
146
147    /// Returns `true` if the element is fully off-screen
148    /// (`Exited`).
149    pub fn is_exited(&self) -> bool {
150        self.get_phase().get() == TransitionPhase::Exited
151    }
152
153    /// Replaces the duration config.
154    ///
155    /// Named `change_config` (not `set_config`) to avoid
156    /// colliding with the `set_config` setter generated
157    /// by `#[derive(Data)]` on the struct field.
158    pub fn change_config(&self, config: TransitionConfig) {
159        self.get_config().set(config);
160    }
161
162    /// Starts the enter animation. Sets the phase to
163    /// `Entering` and resets progress to `0.0`. No-op if
164    /// the transition is already in `Entering` / `Entered`.
165    pub fn enter(&self) {
166        let current: TransitionPhase = self.get_phase().get();
167        if matches!(
168            current,
169            TransitionPhase::Entering | TransitionPhase::Entered
170        ) {
171            return;
172        }
173        self.get_phase().set(TransitionPhase::Entering);
174        self.get_progress().set(0.0);
175    }
176
177    /// Starts the exit animation. Sets the phase to
178    /// `Exiting` and starts progress from `1.0`. No-op if
179    /// the transition is already in `Exiting` / `Exited`.
180    pub fn exit(&self) {
181        let current: TransitionPhase = self.get_phase().get();
182        if matches!(current, TransitionPhase::Exiting | TransitionPhase::Exited) {
183            return;
184        }
185        self.get_phase().set(TransitionPhase::Exiting);
186        self.get_progress().set(1.0);
187    }
188
189    /// Toggles between `Entered` and `Exited`. Equivalent
190    /// to `enter()` if currently `Exiting` / `Exited`,
191    /// and `exit()` if currently `Entering` / `Entered`.
192    pub fn toggle(&self) {
193        match self.get_phase().get() {
194            TransitionPhase::Entered | TransitionPhase::Entering => {
195                self.exit();
196            }
197            TransitionPhase::Exiting | TransitionPhase::Exited => {
198                self.enter();
199            }
200        }
201    }
202
203    /// Advances the transition by `elapsed_ms`
204    /// milliseconds. Updates `progress` and, if the
205    /// transition has reached its end, advances the phase
206    /// to the corresponding terminal phase (`Entered` or
207    /// `Exited`).
208    ///
209    /// This is the primitive the consumer drives from a
210    /// `setInterval` or `requestAnimationFrame` loop. The
211    /// state itself does NOT spawn a timer — that would
212    /// require `wasm_bindgen_futures::spawn_local` and
213    /// would prevent the primitive from being usable on
214    /// native targets. Instead, the consumer is expected
215    /// to wire up the timer (see the docs on
216    /// `tick_until_done` for a helper that drives `tick`
217    /// in a loop).
218    ///
219    /// # Arguments
220    ///
221    /// - `u32` - The number of milliseconds that have
222    ///   elapsed since the last `tick` call. Must be
223    ///   non-negative. The state does not validate this;
224    ///   passing `0` is a no-op, passing a value larger
225    ///   than the remaining duration jumps directly to
226    ///   the terminal phase.
227    pub fn tick(&self, elapsed_ms: u32) {
228        match self.get_phase().get() {
229            TransitionPhase::Exited | TransitionPhase::Entered => {
230                // Terminal phases don't tick.
231            }
232            TransitionPhase::Entering => {
233                let total: u32 = self.get_config().get().enter_ms;
234                let current: f64 = self.get_progress().get();
235                if total == 0 {
236                    // Zero-duration enter jumps straight
237                    // to `Entered`.
238                    self.get_progress().set(1.0);
239                    self.get_phase().set(TransitionPhase::Entered);
240                    return;
241                }
242                let next: f64 = current + (elapsed_ms as f64) / (total as f64);
243                if next >= 1.0 {
244                    self.get_progress().set(1.0);
245                    self.get_phase().set(TransitionPhase::Entered);
246                } else {
247                    self.get_progress().set(next);
248                }
249            }
250            TransitionPhase::Exiting => {
251                let total: u32 = self.get_config().get().exit_ms;
252                let current: f64 = self.get_progress().get();
253                if total == 0 {
254                    self.get_progress().set(0.0);
255                    self.get_phase().set(TransitionPhase::Exited);
256                    return;
257                }
258                let next: f64 = current - (elapsed_ms as f64) / (total as f64);
259                if next <= 0.0 {
260                    self.get_progress().set(0.0);
261                    self.get_phase().set(TransitionPhase::Exited);
262                } else {
263                    self.get_progress().set(next);
264                }
265            }
266        }
267    }
268
269    /// Drives `tick` until the transition reaches a
270    /// terminal phase (`Entered` or `Exited`), using
271    /// `step_ms` as the per-tick delta.
272    ///
273    /// Useful for tests and for native builds that want
274    /// to fast-forward a transition synchronously. On
275    /// wasm the consumer should NOT use this — instead
276    /// drive `tick` from a real timer loop and render
277    /// each frame.
278    ///
279    /// # Arguments
280    ///
281    /// - `u32` - The per-tick delta, in milliseconds.
282    ///   Typical value is `16` (≈60 fps).
283    pub fn tick_until_done(&self, step_ms: u32) {
284        while self.is_animating() {
285            self.tick(step_ms);
286        }
287    }
288
289    /// Resets the transition back to the `Exited` state
290    /// (progress = `0.0`). Cancels any in-flight
291    /// animation immediately. Useful for "the user
292    /// closed the dialog before the exit animation
293    /// finished, force-reset" flows.
294    pub fn reset(&self) {
295        self.get_phase().set(TransitionPhase::Exited);
296        self.get_progress().set(0.0);
297    }
298
299    /// Returns the time remaining (in ms) until the
300    /// current transition completes. Returns `0` for
301    /// terminal phases.
302    pub fn remaining_ms(&self) -> u32 {
303        match self.get_phase().get() {
304            TransitionPhase::Exited | TransitionPhase::Entered => 0,
305            TransitionPhase::Entering => {
306                let total: u32 = self.get_config().get().enter_ms;
307                let current: f64 = self.get_progress().get() * total as f64;
308                (total as f64 - current).max(0.0) as u32
309            }
310            TransitionPhase::Exiting => {
311                let total: u32 = self.get_config().get().exit_ms;
312                let current: f64 = self.get_progress().get() * total as f64;
313                current as u32
314            }
315        }
316    }
317}