Skip to main content

open_gpui_motion/
runtime.rs

1//! Renderer-neutral runtime helpers for deterministic UI motion.
2
3use crate::{MotionPx, MotionRect, MotionSpec, motion_point, motion_rect, motion_size};
4use std::{
5    collections::HashMap,
6    hash::Hash,
7    time::{Duration, Instant},
8};
9
10/// Runtime state for sampled motion.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum MotionRunState {
13    /// The motion spec completed immediately.
14    Immediate,
15    /// The motion run is still active.
16    Active,
17    /// The motion run reached its final state.
18    Completed,
19    /// The motion run was cancelled before reaching its final state.
20    Cancelled,
21}
22
23impl MotionRunState {
24    /// Returns whether callers should continue requesting animation frames.
25    pub const fn is_active(self) -> bool {
26        matches!(self, Self::Active)
27    }
28
29    /// Returns whether the timeline no longer needs animation frames.
30    pub const fn is_terminal(self) -> bool {
31        !self.is_active()
32    }
33
34    /// Returns whether the semantic final state has been reached.
35    pub const fn reached_final_state(self) -> bool {
36        matches!(self, Self::Immediate | Self::Completed)
37    }
38}
39
40/// A sampled point on a motion timeline.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct MotionTimelineSample {
43    state: MotionRunState,
44    elapsed: Duration,
45    raw_progress: f32,
46    progress: f32,
47}
48
49impl MotionTimelineSample {
50    /// Creates a sample from explicit values.
51    pub const fn new(
52        state: MotionRunState,
53        elapsed: Duration,
54        raw_progress: f32,
55        progress: f32,
56    ) -> Self {
57        Self {
58            state,
59            elapsed,
60            raw_progress,
61            progress,
62        }
63    }
64
65    /// Returns the sampled timeline state.
66    pub const fn state(self) -> MotionRunState {
67        self.state
68    }
69
70    /// Returns the elapsed time used for this sample.
71    pub const fn elapsed(self) -> Duration {
72        self.elapsed
73    }
74
75    /// Returns the unclamped easing input after duration normalization.
76    pub const fn raw_progress(self) -> f32 {
77        self.raw_progress
78    }
79
80    /// Returns the eased progress.
81    pub const fn progress(self) -> f32 {
82        self.progress
83    }
84
85    /// Returns whether the timeline should continue requesting frames.
86    pub const fn is_active(self) -> bool {
87        self.state.is_active()
88    }
89
90    /// Returns whether the semantic final state has been reached.
91    pub const fn reached_final_state(self) -> bool {
92        self.state.reached_final_state()
93    }
94}
95
96/// A deterministic timeline for one UI motion transition.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct MotionTimeline {
99    spec: MotionSpec,
100    started_at: Instant,
101    cancelled_at: Option<Instant>,
102}
103
104impl MotionTimeline {
105    /// Creates a timeline that starts at the provided instant.
106    pub const fn new(spec: MotionSpec, started_at: Instant) -> Self {
107        Self {
108            spec,
109            started_at,
110            cancelled_at: None,
111        }
112    }
113
114    /// Returns the motion specification used by this timeline.
115    pub const fn spec(self) -> MotionSpec {
116        self.spec
117    }
118
119    /// Returns the instant at which the timeline started.
120    pub const fn started_at(self) -> Instant {
121        self.started_at
122    }
123
124    /// Returns the instant at which the timeline was cancelled.
125    pub const fn cancelled_at(self) -> Option<Instant> {
126        self.cancelled_at
127    }
128
129    /// Marks the timeline as cancelled at the provided instant.
130    pub fn cancel_at(&mut self, cancelled_at: Instant) {
131        self.cancelled_at = Some(cancelled_at);
132    }
133
134    /// Samples the timeline at the provided instant.
135    pub fn sample(self, now: Instant) -> MotionTimelineSample {
136        let effective_now = self.cancelled_at.unwrap_or(now);
137        let elapsed = effective_now.saturating_duration_since(self.started_at);
138        let mut sample = Self::sample_elapsed(self.spec, elapsed);
139        if self.cancelled_at.is_some() && !sample.reached_final_state() {
140            sample.state = MotionRunState::Cancelled;
141        }
142        sample
143    }
144
145    /// Samples a motion spec using an explicit elapsed duration.
146    pub fn sample_elapsed(spec: MotionSpec, elapsed: Duration) -> MotionTimelineSample {
147        if spec.is_immediate() {
148            return MotionTimelineSample::new(MotionRunState::Immediate, elapsed, 1.0, 1.0);
149        }
150
151        let duration = spec.duration().as_duration();
152        if duration.is_zero() {
153            return MotionTimelineSample::new(MotionRunState::Immediate, elapsed, 1.0, 1.0);
154        }
155
156        let raw_progress = (elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0);
157        let progress = spec.easing().sample(raw_progress);
158        let state = if raw_progress >= 1.0 {
159            MotionRunState::Completed
160        } else {
161            MotionRunState::Active
162        };
163        MotionTimelineSample::new(state, elapsed, raw_progress, progress)
164    }
165}
166
167/// A stable-id value captured from a motion sample or target state.
168#[derive(Debug, Clone, PartialEq)]
169pub struct MotionSnapshot<K, V> {
170    id: K,
171    value: V,
172}
173
174impl<K, V> MotionSnapshot<K, V> {
175    /// Creates a stable-id snapshot.
176    pub const fn new(id: K, value: V) -> Self {
177        Self { id, value }
178    }
179
180    /// Returns the stable identity.
181    pub const fn id(&self) -> &K {
182        &self.id
183    }
184
185    /// Returns the captured value.
186    pub const fn value(&self) -> &V {
187        &self.value
188    }
189
190    /// Consumes the snapshot and returns its parts.
191    pub fn into_parts(self) -> (K, V) {
192        (self.id, self.value)
193    }
194}
195
196/// A target item paired with the currently sampled value for the same identity.
197#[derive(Debug, Clone, PartialEq)]
198pub struct MotionRetargetItem<K, S, T = S> {
199    id: K,
200    sampled: Option<S>,
201    target: T,
202}
203
204impl<K, S, T> MotionRetargetItem<K, S, T> {
205    /// Creates a retargeted item.
206    pub const fn new(id: K, sampled: Option<S>, target: T) -> Self {
207        Self {
208            id,
209            sampled,
210            target,
211        }
212    }
213
214    /// Returns the stable identity.
215    pub const fn id(&self) -> &K {
216        &self.id
217    }
218
219    /// Returns the sampled value when the identity existed in the interrupted transition.
220    pub const fn sampled(&self) -> Option<&S> {
221        self.sampled.as_ref()
222    }
223
224    /// Returns the target value.
225    pub const fn target(&self) -> &T {
226        &self.target
227    }
228
229    /// Consumes the item and returns its parts.
230    pub fn into_parts(self) -> (K, Option<S>, T) {
231        (self.id, self.sampled, self.target)
232    }
233}
234
235/// Stable-id retargeting result for a new target set.
236#[derive(Debug, Clone, PartialEq)]
237pub struct MotionRetargetSet<K, S, T = S> {
238    targets: Vec<MotionRetargetItem<K, S, T>>,
239    leaving: Vec<MotionSnapshot<K, S>>,
240}
241
242impl<K, S, T> MotionRetargetSet<K, S, T> {
243    /// Creates a retarget set.
244    pub fn new(
245        targets: Vec<MotionRetargetItem<K, S, T>>,
246        leaving: Vec<MotionSnapshot<K, S>>,
247    ) -> Self {
248        Self { targets, leaving }
249    }
250
251    /// Returns the target items in target order.
252    pub fn targets(&self) -> &[MotionRetargetItem<K, S, T>] {
253        &self.targets
254    }
255
256    /// Returns sampled items that were not present in the target set.
257    pub fn leaving(&self) -> &[MotionSnapshot<K, S>] {
258        &self.leaving
259    }
260
261    /// Consumes the set and returns its parts.
262    pub fn into_parts(self) -> (Vec<MotionRetargetItem<K, S, T>>, Vec<MotionSnapshot<K, S>>) {
263        (self.targets, self.leaving)
264    }
265}
266
267/// Matches sampled values to a new target set by stable identity.
268///
269/// The returned targets preserve target order. The returned leaving snapshots preserve sampled
270/// order for identities that are absent from the target set.
271pub fn retarget_motion_snapshots<K, S, T>(
272    sampled: impl IntoIterator<Item = MotionSnapshot<K, S>>,
273    targets: impl IntoIterator<Item = MotionSnapshot<K, T>>,
274) -> MotionRetargetSet<K, S, T>
275where
276    K: Clone + Eq + Hash,
277{
278    let mut sampled = sampled.into_iter().map(Some).collect::<Vec<_>>();
279    let mut sampled_indices = HashMap::new();
280    for (index, snapshot) in sampled.iter().enumerate() {
281        if let Some(snapshot) = snapshot {
282            sampled_indices.entry(snapshot.id.clone()).or_insert(index);
283        }
284    }
285
286    let targets = targets
287        .into_iter()
288        .map(|target| {
289            let (id, target_value) = target.into_parts();
290            let sampled_value = sampled_indices
291                .get(&id)
292                .and_then(|index| sampled[*index].take())
293                .map(|snapshot| snapshot.value);
294            MotionRetargetItem::new(id, sampled_value, target_value)
295        })
296        .collect::<Vec<_>>();
297    let leaving = sampled.into_iter().flatten().collect::<Vec<_>>();
298
299    MotionRetargetSet::new(targets, leaving)
300}
301
302/// Logical edge used by renderer-neutral rect motion helpers.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
304pub enum MotionEdge {
305    /// The left edge.
306    Left,
307    /// The right edge.
308    Right,
309    /// The top edge.
310    Top,
311    /// The bottom edge.
312    Bottom,
313}
314
315/// Chooses the best edge to move a rect out of a containing rect.
316///
317/// A rect touching an edge prefers that edge before distance comparisons. This keeps retargeted
318/// split and zoom panes moving toward the window edge they visually belong to.
319pub fn preferred_motion_edge(bounds: MotionRect, container: MotionRect) -> MotionEdge {
320    let left = (bounds.origin.x - container.origin.x).as_f32().abs();
321    let right = (rect_right(container) - rect_right(bounds)).abs();
322    let top = (bounds.origin.y - container.origin.y).as_f32().abs();
323    let bottom = (rect_bottom(container) - rect_bottom(bounds)).abs();
324    let touching_epsilon = 0.5_f32;
325
326    if left <= touching_epsilon {
327        return MotionEdge::Left;
328    }
329    if right <= touching_epsilon {
330        return MotionEdge::Right;
331    }
332    if top <= touching_epsilon {
333        return MotionEdge::Top;
334    }
335    if bottom <= touching_epsilon {
336        return MotionEdge::Bottom;
337    }
338
339    [
340        (MotionEdge::Left, left),
341        (MotionEdge::Right, right),
342        (MotionEdge::Top, top),
343        (MotionEdge::Bottom, bottom),
344    ]
345    .into_iter()
346    .min_by(|(_, a), (_, b)| a.total_cmp(b))
347    .map(|(edge, _)| edge)
348    .unwrap_or(MotionEdge::Left)
349}
350
351/// Returns bounds for the rect just outside the container on the chosen edge.
352pub fn motion_source_rect(
353    edge: MotionEdge,
354    final_bounds: MotionRect,
355    container: MotionRect,
356) -> MotionRect {
357    let origin = match edge {
358        MotionEdge::Left => motion_point(
359            container.origin.x - final_bounds.size.width,
360            final_bounds.origin.y,
361        ),
362        MotionEdge::Right => {
363            motion_point(MotionPx::new(rect_right(container)), final_bounds.origin.y)
364        }
365        MotionEdge::Top => motion_point(
366            final_bounds.origin.x,
367            container.origin.y - final_bounds.size.height,
368        ),
369        MotionEdge::Bottom => {
370            motion_point(final_bounds.origin.x, MotionPx::new(rect_bottom(container)))
371        }
372    };
373    motion_rect(origin, final_bounds.size)
374}
375
376/// Samples the visible sub-rect revealed from an edge at unit progress.
377pub fn reveal_rect_from_edge(
378    final_bounds: MotionRect,
379    edge: MotionEdge,
380    progress: f32,
381) -> MotionRect {
382    let progress = progress.clamp(0.0, 1.0);
383    match edge {
384        MotionEdge::Left => {
385            let width = final_bounds.size.width * progress;
386            motion_rect(
387                final_bounds.origin,
388                motion_size(width, final_bounds.size.height),
389            )
390        }
391        MotionEdge::Right => {
392            let width = final_bounds.size.width * progress;
393            motion_rect(
394                motion_point(
395                    MotionPx::new(rect_right(final_bounds)) - width,
396                    final_bounds.origin.y,
397                ),
398                motion_size(width, final_bounds.size.height),
399            )
400        }
401        MotionEdge::Top => {
402            let height = final_bounds.size.height * progress;
403            motion_rect(
404                final_bounds.origin,
405                motion_size(final_bounds.size.width, height),
406            )
407        }
408        MotionEdge::Bottom => {
409            let height = final_bounds.size.height * progress;
410            motion_rect(
411                motion_point(
412                    final_bounds.origin.x,
413                    MotionPx::new(rect_bottom(final_bounds)) - height,
414                ),
415                motion_size(final_bounds.size.width, height),
416            )
417        }
418    }
419}
420
421/// Samples a rect between two rects at unit progress.
422pub fn lerp_rect(from: MotionRect, to: MotionRect, progress: f32) -> MotionRect {
423    let progress = progress.clamp(0.0, 1.0);
424    motion_rect(
425        motion_point(
426            lerp_px(from.origin.x, to.origin.x, progress),
427            lerp_px(from.origin.y, to.origin.y, progress),
428        ),
429        motion_size(
430            lerp_px(from.size.width, to.size.width, progress),
431            lerp_px(from.size.height, to.size.height, progress),
432        ),
433    )
434}
435
436fn lerp_px(from: MotionPx, to: MotionPx, progress: f32) -> MotionPx {
437    MotionPx::new(from.as_f32() + (to.as_f32() - from.as_f32()) * progress)
438}
439
440fn rect_right(bounds: MotionRect) -> f32 {
441    bounds.origin.x.as_f32() + bounds.size.width.as_f32()
442}
443
444fn rect_bottom(bounds: MotionRect) -> f32 {
445    bounds.origin.y.as_f32() + bounds.size.height.as_f32()
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::{MotionDuration, MotionEasing, MotionPreference};
452
453    #[test]
454    fn timeline_samples_start_midpoint_and_completion() {
455        let started_at = Instant::now();
456        let spec = MotionSpec::new(
457            MotionPreference::Animated,
458            MotionDuration::Custom(Duration::from_millis(200)),
459            MotionEasing::Linear,
460        );
461        let timeline = MotionTimeline::new(spec, started_at);
462
463        let start = timeline.sample(started_at);
464        assert_eq!(start.state(), MotionRunState::Active);
465        assert_eq!(start.elapsed(), Duration::from_millis(0));
466        assert_eq!(start.raw_progress(), 0.0);
467        assert_eq!(start.progress(), 0.0);
468
469        let midpoint = timeline.sample(started_at + Duration::from_millis(100));
470        assert_eq!(midpoint.state(), MotionRunState::Active);
471        assert_eq!(midpoint.raw_progress(), 0.5);
472        assert_eq!(midpoint.progress(), 0.5);
473
474        let complete = timeline.sample(started_at + Duration::from_millis(250));
475        assert_eq!(complete.state(), MotionRunState::Completed);
476        assert_eq!(complete.raw_progress(), 1.0);
477        assert_eq!(complete.progress(), 1.0);
478        assert!(complete.reached_final_state());
479    }
480
481    #[test]
482    fn reduced_motion_samples_as_immediate() {
483        let sample = MotionTimeline::sample_elapsed(
484            MotionSpec::layout(MotionPreference::Reduced),
485            Duration::from_millis(0),
486        );
487
488        assert_eq!(sample.state(), MotionRunState::Immediate);
489        assert_eq!(sample.raw_progress(), 1.0);
490        assert_eq!(sample.progress(), 1.0);
491        assert!(sample.reached_final_state());
492    }
493
494    #[test]
495    fn cancelled_timeline_reports_cancelled_sampled_progress() {
496        let started_at = Instant::now();
497        let spec = MotionSpec::new(
498            MotionPreference::Animated,
499            MotionDuration::Custom(Duration::from_millis(200)),
500            MotionEasing::Linear,
501        );
502        let mut timeline = MotionTimeline::new(spec, started_at);
503
504        timeline.cancel_at(started_at + Duration::from_millis(80));
505        let sample = timeline.sample(started_at + Duration::from_millis(160));
506
507        assert_eq!(sample.state(), MotionRunState::Cancelled);
508        assert!((sample.raw_progress() - 0.4).abs() < f32::EPSILON);
509        assert!((sample.progress() - 0.4).abs() < f32::EPSILON);
510        assert!(!sample.reached_final_state());
511    }
512
513    #[test]
514    fn retarget_snapshots_match_by_identity_and_report_missing_items() {
515        let retarget = retarget_motion_snapshots(
516            [
517                MotionSnapshot::new("left", 0.25),
518                MotionSnapshot::new("center", 0.5),
519                MotionSnapshot::new("right", 0.25),
520            ],
521            [
522                MotionSnapshot::new("center", 0.7),
523                MotionSnapshot::new("inspector", 0.3),
524            ],
525        );
526
527        assert_eq!(retarget.targets().len(), 2);
528        assert_eq!(retarget.targets()[0].id(), &"center");
529        assert_eq!(retarget.targets()[0].sampled(), Some(&0.5));
530        assert_eq!(retarget.targets()[0].target(), &0.7);
531        assert_eq!(retarget.targets()[1].id(), &"inspector");
532        assert_eq!(retarget.targets()[1].sampled(), None);
533        assert_eq!(retarget.targets()[1].target(), &0.3);
534
535        let leaving_ids = retarget
536            .leaving()
537            .iter()
538            .map(MotionSnapshot::id)
539            .copied()
540            .collect::<Vec<_>>();
541        assert_eq!(leaving_ids, ["left", "right"]);
542    }
543
544    #[test]
545    fn preferred_motion_edge_prefers_touching_edge_before_distance() {
546        let container = motion_rect(
547            motion_point(MotionPx::ZERO, MotionPx::ZERO),
548            motion_size(MotionPx::new(400.0), MotionPx::new(240.0)),
549        );
550        let touching_top_but_closer_left = motion_rect(
551            motion_point(MotionPx::new(20.0), MotionPx::ZERO),
552            motion_size(MotionPx::new(80.0), MotionPx::new(80.0)),
553        );
554
555        assert_eq!(
556            preferred_motion_edge(touching_top_but_closer_left, container),
557            MotionEdge::Top
558        );
559    }
560
561    #[test]
562    fn motion_source_rect_places_rect_outside_container_edge() {
563        let container = motion_rect(
564            motion_point(MotionPx::ZERO, MotionPx::ZERO),
565            motion_size(MotionPx::new(400.0), MotionPx::new(240.0)),
566        );
567        let final_bounds = motion_rect(
568            motion_point(MotionPx::new(40.0), MotionPx::new(20.0)),
569            motion_size(MotionPx::new(80.0), MotionPx::new(60.0)),
570        );
571
572        assert_eq!(
573            motion_source_rect(MotionEdge::Left, final_bounds, container),
574            motion_rect(
575                motion_point(MotionPx::new(-80.0), MotionPx::new(20.0)),
576                final_bounds.size
577            )
578        );
579        assert_eq!(
580            motion_source_rect(MotionEdge::Bottom, final_bounds, container),
581            motion_rect(
582                motion_point(MotionPx::new(40.0), MotionPx::new(240.0)),
583                final_bounds.size
584            )
585        );
586    }
587
588    #[test]
589    fn reveal_and_lerp_rect_clamp_progress() {
590        let rect = motion_rect(
591            motion_point(MotionPx::new(10.0), MotionPx::new(20.0)),
592            motion_size(MotionPx::new(100.0), MotionPx::new(80.0)),
593        );
594
595        assert_eq!(
596            reveal_rect_from_edge(rect, MotionEdge::Right, 0.25),
597            motion_rect(
598                motion_point(MotionPx::new(85.0), MotionPx::new(20.0)),
599                motion_size(MotionPx::new(25.0), MotionPx::new(80.0))
600            )
601        );
602        assert_eq!(lerp_rect(rect, rect, 2.0), rect);
603    }
604}