Skip to main content

open_gpui_motion/
projection.rs

1//! Renderer-neutral layout projection primitives.
2
3use crate::{
4    MotionEdge, MotionPoint, MotionPreference, MotionPx, MotionRect, motion_point, motion_rect,
5    motion_size, reveal_rect_from_edge,
6};
7
8const TRANSLATE_EPSILON: f32 = 0.01;
9const SCALE_EPSILON: f32 = 0.000_1;
10
11#[derive(Debug, Clone, Copy, PartialEq)]
12struct MotionProjectionScale {
13    x: f32,
14    y: f32,
15}
16
17impl MotionProjectionScale {
18    const fn new(x: f32, y: f32) -> Self {
19        Self { x, y }
20    }
21
22    const fn x(self) -> f32 {
23        self.x
24    }
25
26    const fn y(self) -> f32 {
27        self.y
28    }
29}
30
31/// Projection from previous geometry to final target geometry.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct MotionProjection {
34    source: MotionRect,
35    target: MotionRect,
36}
37
38impl MotionProjection {
39    /// Creates a projection between a source rect and final target rect.
40    pub const fn between(source: MotionRect, target: MotionRect) -> Self {
41        Self { source, target }
42    }
43
44    /// Returns the source rect.
45    pub const fn source(self) -> MotionRect {
46        self.source
47    }
48
49    /// Returns the final target rect.
50    pub const fn target(self) -> MotionRect {
51        self.target
52    }
53
54    /// Returns the sampled visual bounds after applying projection movement to final-size content.
55    pub fn visual_bounds(self, progress: f32) -> MotionRect {
56        self.visual_bounds_with_preference(progress, MotionPreference::Animated)
57    }
58
59    /// Returns the sampled visual bounds while honoring reduced-motion final-state semantics.
60    pub fn visual_bounds_with_preference(
61        self,
62        progress: f32,
63        preference: MotionPreference,
64    ) -> MotionRect {
65        self.sample_with_preference(progress, preference)
66            .visual_bounds()
67    }
68
69    fn sample_with_preference(
70        self,
71        progress: f32,
72        preference: MotionPreference,
73    ) -> MotionProjectionSample {
74        let progress = if preference.is_immediate() {
75            1.0
76        } else {
77            progress.clamp(0.0, 1.0)
78        };
79        let source_translation = self.source_translation();
80        let source_scale = self.source_scale();
81        let translation = motion_point(
82            MotionPx::new(source_translation.x.as_f32() * (1.0 - progress)),
83            MotionPx::new(source_translation.y.as_f32() * (1.0 - progress)),
84        );
85        let scale = MotionProjectionScale::new(
86            lerp_scale(source_scale.x(), 1.0, progress),
87            lerp_scale(source_scale.y(), 1.0, progress),
88        );
89
90        MotionProjectionSample::new(
91            self.target,
92            progress,
93            snap_translation(translation),
94            snap_scale(scale),
95        )
96    }
97
98    fn source_translation(self) -> MotionPoint {
99        snap_translation(motion_point(
100            self.source.origin.x - self.target.origin.x,
101            self.source.origin.y - self.target.origin.y,
102        ))
103    }
104
105    fn source_scale(self) -> MotionProjectionScale {
106        snap_scale(MotionProjectionScale::new(
107            divide_or_identity(self.source.size.width, self.target.size.width),
108            divide_or_identity(self.source.size.height, self.target.size.height),
109        ))
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
114struct MotionProjectionSample {
115    target_bounds: MotionRect,
116    progress: f32,
117    translation: MotionPoint,
118    scale: MotionProjectionScale,
119}
120
121/// Sampled clip for rendering final-size content through a moving or revealing viewport.
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct MotionProjectionClip {
124    content_bounds: MotionRect,
125    visible_bounds: MotionRect,
126    occlusion_bounds: MotionRect,
127    progress: f32,
128}
129
130impl MotionProjectionClip {
131    /// Creates a sampled clip from explicit final-content, visible, and occlusion bounds.
132    pub fn new(
133        content_bounds: MotionRect,
134        visible_bounds: MotionRect,
135        occlusion_bounds: MotionRect,
136        progress: f32,
137    ) -> Self {
138        Self {
139            content_bounds,
140            visible_bounds,
141            occlusion_bounds,
142            progress: progress.clamp(0.0, 1.0),
143        }
144    }
145
146    /// Creates a projection clip from previous geometry to final semantic geometry.
147    pub fn from_projection(projection: MotionProjection, progress: f32) -> Self {
148        Self::from_projection_with_preference(projection, progress, MotionPreference::Animated)
149    }
150
151    /// Creates a projection clip while honoring reduced-motion final-state semantics.
152    pub fn from_projection_with_preference(
153        projection: MotionProjection,
154        progress: f32,
155        preference: MotionPreference,
156    ) -> Self {
157        let sample = projection.sample_with_preference(progress, preference);
158        Self::new(
159            sample.target_bounds(),
160            sample.visual_bounds(),
161            sample.target_bounds(),
162            sample.progress(),
163        )
164    }
165
166    /// Creates a reveal clip inside final content bounds from the chosen edge.
167    pub fn reveal(content_bounds: MotionRect, edge: MotionEdge, progress: f32) -> Self {
168        let progress = progress.clamp(0.0, 1.0);
169        Self::new(
170            content_bounds,
171            reveal_rect_from_edge(content_bounds, edge, progress),
172            content_bounds,
173            progress,
174        )
175    }
176
177    /// Returns the final-size content bounds.
178    pub const fn content_bounds(self) -> MotionRect {
179        self.content_bounds
180    }
181
182    /// Returns the sampled visible viewport bounds.
183    pub const fn visible_bounds(self) -> MotionRect {
184        self.visible_bounds
185    }
186
187    /// Returns the occlusion bounds that cover the snapped final scene underneath.
188    pub const fn occlusion_bounds(self) -> MotionRect {
189        self.occlusion_bounds
190    }
191
192    /// Returns clamped clip progress.
193    pub const fn progress(self) -> f32 {
194        self.progress
195    }
196}
197
198impl MotionProjectionSample {
199    const fn new(
200        target_bounds: MotionRect,
201        progress: f32,
202        translation: MotionPoint,
203        scale: MotionProjectionScale,
204    ) -> Self {
205        Self {
206            target_bounds,
207            progress,
208            translation,
209            scale,
210        }
211    }
212
213    const fn target_bounds(self) -> MotionRect {
214        self.target_bounds
215    }
216
217    fn visual_bounds(self) -> MotionRect {
218        motion_rect(
219            motion_point(
220                self.target_bounds.origin.x + self.translation.x,
221                self.target_bounds.origin.y + self.translation.y,
222            ),
223            motion_size(
224                self.target_bounds.size.width * self.scale.x(),
225                self.target_bounds.size.height * self.scale.y(),
226            ),
227        )
228    }
229
230    const fn progress(self) -> f32 {
231        self.progress
232    }
233}
234
235fn divide_or_identity(from: MotionPx, to: MotionPx) -> f32 {
236    let denominator = to.as_f32();
237    if denominator.abs() <= f32::EPSILON || !denominator.is_finite() {
238        1.0
239    } else {
240        sanitize_scale(from.as_f32() / denominator)
241    }
242}
243
244fn sanitize_scale(scale: f32) -> f32 {
245    if scale.is_finite() && scale > 0.0 {
246        scale
247    } else {
248        1.0
249    }
250}
251
252fn snap_translation(point: MotionPoint) -> MotionPoint {
253    motion_point(
254        MotionPx::new(snap_zero(point.x.as_f32(), TRANSLATE_EPSILON)),
255        MotionPx::new(snap_zero(point.y.as_f32(), TRANSLATE_EPSILON)),
256    )
257}
258
259fn snap_scale(scale: MotionProjectionScale) -> MotionProjectionScale {
260    MotionProjectionScale::new(
261        snap_one(scale.x(), SCALE_EPSILON),
262        snap_one(scale.y(), SCALE_EPSILON),
263    )
264}
265
266fn snap_zero(value: f32, epsilon: f32) -> f32 {
267    if value.abs() <= epsilon { 0.0 } else { value }
268}
269
270fn snap_one(value: f32, epsilon: f32) -> f32 {
271    if (value - 1.0).abs() <= epsilon {
272        1.0
273    } else {
274        value
275    }
276}
277
278fn lerp_scale(from: f32, to: f32, progress: f32) -> f32 {
279    from + (to - from) * progress
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::{MotionEdge, MotionPreference, MotionPx, motion_point, motion_rect, motion_size};
286
287    fn rect(x: f32, y: f32, width: f32, height: f32) -> crate::MotionRect {
288        motion_rect(
289            motion_point(MotionPx::new(x), MotionPx::new(y)),
290            motion_size(MotionPx::new(width), MotionPx::new(height)),
291        )
292    }
293
294    #[test]
295    fn projection_visual_bounds_move_without_mutating_target_bounds() {
296        let projection = MotionProjection::between(
297            rect(10.0, 20.0, 100.0, 50.0),
298            rect(30.0, 60.0, 200.0, 100.0),
299        );
300
301        assert_eq!(projection.target(), rect(30.0, 60.0, 200.0, 100.0));
302        assert_eq!(projection.visual_bounds(0.0), rect(10.0, 20.0, 100.0, 50.0));
303        assert_eq!(
304            projection.visual_bounds(1.0),
305            rect(30.0, 60.0, 200.0, 100.0)
306        );
307    }
308
309    #[test]
310    fn projection_clip_keeps_final_content_bounds_and_samples_visible_bounds() {
311        let projection = MotionProjection::between(
312            rect(10.0, 20.0, 100.0, 50.0),
313            rect(30.0, 60.0, 200.0, 100.0),
314        );
315
316        let start = MotionProjectionClip::from_projection(projection, 0.0);
317        assert_eq!(start.content_bounds(), rect(30.0, 60.0, 200.0, 100.0));
318        assert_eq!(start.visible_bounds(), rect(10.0, 20.0, 100.0, 50.0));
319        assert_eq!(start.occlusion_bounds(), rect(30.0, 60.0, 200.0, 100.0));
320
321        let end = MotionProjectionClip::from_projection(projection, 1.0);
322        assert_eq!(end.content_bounds(), rect(30.0, 60.0, 200.0, 100.0));
323        assert_eq!(end.visible_bounds(), rect(30.0, 60.0, 200.0, 100.0));
324    }
325
326    #[test]
327    fn reveal_clip_samples_visible_bounds_inside_final_content() {
328        let clip =
329            MotionProjectionClip::reveal(rect(10.0, 20.0, 100.0, 50.0), MotionEdge::Left, 0.25);
330
331        assert_eq!(clip.content_bounds(), rect(10.0, 20.0, 100.0, 50.0));
332        assert_eq!(clip.visible_bounds(), rect(10.0, 20.0, 25.0, 50.0));
333        assert_eq!(clip.occlusion_bounds(), rect(10.0, 20.0, 100.0, 50.0));
334        assert_eq!(clip.progress(), 0.25);
335    }
336
337    #[test]
338    fn near_identity_projection_snaps_to_neutral_values() {
339        let projection = MotionProjection::between(
340            rect(10.002, 20.002, 100.001, 50.001),
341            rect(10.0, 20.0, 100.0, 50.0),
342        );
343
344        assert_eq!(projection.visual_bounds(0.0), rect(10.0, 20.0, 100.0, 50.0));
345    }
346
347    #[test]
348    fn reduced_motion_projection_samples_final_state_without_spatial_movement() {
349        let projection =
350            MotionProjection::between(rect(0.0, 0.0, 100.0, 50.0), rect(50.0, 30.0, 100.0, 50.0));
351
352        assert_eq!(
353            projection.visual_bounds_with_preference(0.25, MotionPreference::Reduced),
354            rect(50.0, 30.0, 100.0, 50.0)
355        );
356    }
357
358    #[test]
359    fn public_visual_bounds_helper_exposes_consumed_projection_capability() {
360        let projection = MotionProjection::between(
361            rect(10.0, 20.0, 100.0, 50.0),
362            rect(30.0, 60.0, 200.0, 100.0),
363        );
364
365        assert_eq!(projection.visual_bounds(0.0), rect(10.0, 20.0, 100.0, 50.0));
366        assert_eq!(
367            projection.visual_bounds(1.0),
368            rect(30.0, 60.0, 200.0, 100.0)
369        );
370        assert_eq!(
371            projection.visual_bounds_with_preference(0.25, MotionPreference::Reduced),
372            rect(30.0, 60.0, 200.0, 100.0)
373        );
374    }
375
376    #[test]
377    fn reveal_sample_uses_final_bounds_as_the_semantic_rect() {
378        let target = rect(30.0, 60.0, 200.0, 100.0);
379        let projection = MotionProjection::between(rect(10.0, 20.0, 100.0, 50.0), target);
380        let clip = MotionProjectionClip::from_projection(projection, 0.25);
381
382        assert_eq!(clip.content_bounds(), target);
383        assert_eq!(clip.occlusion_bounds(), target);
384    }
385}