Skip to main content

open_gpui_canvas/
geometry.rs

1use open_gpui::{Bounds, Pixels, Point, Size};
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4
5#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
6pub struct CanvasViewport {
7    pub origin: Point<Pixels>,
8    pub zoom: f32,
9}
10
11impl Default for CanvasViewport {
12    fn default() -> Self {
13        Self {
14            origin: Point::default(),
15            zoom: 1.0,
16        }
17    }
18}
19
20impl CanvasViewport {
21    pub fn new(origin: Point<Pixels>, zoom: f32) -> Result<Self, TransformError> {
22        if !zoom.is_finite() || zoom <= 0.0 {
23            return Err(TransformError::InvalidZoom(zoom));
24        }
25
26        Ok(Self { origin, zoom })
27    }
28
29    pub fn pan_by(&mut self, delta: Point<Pixels>) {
30        self.origin += delta;
31    }
32
33    pub fn set_zoom(&mut self, zoom: f32) -> Result<(), TransformError> {
34        if !zoom.is_finite() || zoom <= 0.0 {
35            return Err(TransformError::InvalidZoom(zoom));
36        }
37
38        self.zoom = zoom;
39        Ok(())
40    }
41
42    pub fn set_zoom_around_view_point(
43        &mut self,
44        zoom: f32,
45        view_point: Point<Pixels>,
46    ) -> Result<(), TransformError> {
47        let document_point = self.view_to_document(view_point);
48        self.set_zoom(zoom)?;
49        self.origin = document_point - (view_point * (1.0 / self.zoom));
50        Ok(())
51    }
52
53    pub fn zoom_by_around_view_point(
54        &mut self,
55        factor: f32,
56        view_point: Point<Pixels>,
57    ) -> Result<(), TransformError> {
58        if !factor.is_finite() || factor <= 0.0 {
59            return Err(TransformError::InvalidZoomFactor(factor));
60        }
61
62        self.set_zoom_around_view_point(self.zoom * factor, view_point)
63    }
64
65    pub fn document_to_view(&self, point: Point<Pixels>) -> Point<Pixels> {
66        (point - self.origin) * self.zoom
67    }
68
69    pub fn view_to_document(&self, point: Point<Pixels>) -> Point<Pixels> {
70        (point * (1.0 / self.zoom)) + self.origin
71    }
72
73    pub fn document_bounds_to_view(&self, bounds: Bounds<Pixels>) -> Bounds<Pixels> {
74        Bounds::new(
75            self.document_to_view(bounds.origin),
76            Size::new(
77                bounds.size.width * self.zoom,
78                bounds.size.height * self.zoom,
79            ),
80        )
81    }
82
83    pub fn view_bounds_to_document(&self, bounds: Bounds<Pixels>) -> Bounds<Pixels> {
84        Bounds::new(
85            self.view_to_document(bounds.origin),
86            Size::new(
87                bounds.size.width * (1.0 / self.zoom),
88                bounds.size.height * (1.0 / self.zoom),
89            ),
90        )
91    }
92
93    pub fn visible_document_bounds(&self, view_size: Size<Pixels>) -> Bounds<Pixels> {
94        self.view_bounds_to_document(Bounds::new(Point::default(), view_size))
95    }
96}
97
98#[derive(Debug, Error, PartialEq)]
99pub enum TransformError {
100    #[error("invalid canvas zoom `{0}`")]
101    InvalidZoom(f32),
102    #[error("invalid canvas zoom factor `{0}`")]
103    InvalidZoomFactor(f32),
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use open_gpui::{point, px};
110
111    #[test]
112    fn round_trips_between_view_and_document_space() {
113        let viewport = CanvasViewport::new(point(px(100.0), px(50.0)), 2.0).unwrap();
114        let document = point(px(110.0), px(70.0));
115        let view = viewport.document_to_view(document);
116
117        assert_eq!(view, point(px(20.0), px(40.0)));
118        assert_eq!(viewport.view_to_document(view), document);
119    }
120
121    #[test]
122    fn rejects_non_positive_zoom() {
123        assert_eq!(
124            CanvasViewport::new(point(px(0.0), px(0.0)), 0.0).unwrap_err(),
125            TransformError::InvalidZoom(0.0)
126        );
127    }
128
129    #[test]
130    fn zooms_around_view_point_without_moving_document_anchor() {
131        let mut viewport = CanvasViewport::new(point(px(100.0), px(50.0)), 1.0).unwrap();
132        let view_point = point(px(20.0), px(10.0));
133        let document_point = viewport.view_to_document(view_point);
134
135        viewport
136            .set_zoom_around_view_point(2.0, view_point)
137            .unwrap();
138
139        assert_eq!(viewport.view_to_document(view_point), document_point);
140        assert_eq!(viewport.zoom, 2.0);
141    }
142
143    #[test]
144    fn rejects_invalid_zoom_factor() {
145        let mut viewport = CanvasViewport::default();
146
147        assert_eq!(
148            viewport
149                .zoom_by_around_view_point(0.0, point(px(0.0), px(0.0)))
150                .unwrap_err(),
151            TransformError::InvalidZoomFactor(0.0)
152        );
153    }
154
155    #[test]
156    fn returns_visible_document_bounds() {
157        let viewport = CanvasViewport::new(point(px(100.0), px(50.0)), 2.0).unwrap();
158
159        let bounds = viewport.visible_document_bounds(open_gpui::size(px(200.0), px(100.0)));
160
161        assert_eq!(bounds.origin, point(px(100.0), px(50.0)));
162        assert_eq!(bounds.size, open_gpui::size(px(100.0), px(50.0)));
163    }
164}