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}