egui/containers/
scene.rs

1use core::f32;
2
3use emath::{GuiRounding, Pos2};
4
5use crate::{
6    emath::TSTransform, InnerResponse, LayerId, Rangef, Rect, Response, Sense, Ui, UiBuilder, Vec2,
7};
8
9/// Creates a transformation that fits a given scene rectangle into the available screen size.
10///
11/// The resulting visual scene bounds can be larger, due to letterboxing.
12///
13/// Returns the transformation from `scene` to `global` coordinates.
14fn fit_to_rect_in_scene(
15    rect_in_global: Rect,
16    rect_in_scene: Rect,
17    zoom_range: Rangef,
18) -> TSTransform {
19    // Compute the scale factor to fit the bounding rectangle into the available screen size:
20    let scale = rect_in_global.size() / rect_in_scene.size();
21
22    // Use the smaller of the two scales to ensure the whole rectangle fits on the screen:
23    let scale = scale.min_elem();
24
25    // Clamp scale to what is allowed
26    let scale = zoom_range.clamp(scale);
27
28    // Compute the translation to center the bounding rect in the screen:
29    let center_in_global = rect_in_global.center().to_vec2();
30    let center_scene = rect_in_scene.center().to_vec2();
31
32    // Set the transformation to scale and then translate to center.
33    TSTransform::from_translation(center_in_global - scale * center_scene)
34        * TSTransform::from_scaling(scale)
35}
36
37/// A container that allows you to zoom and pan.
38///
39/// This is similar to [`crate::ScrollArea`] but:
40/// * Supports zooming
41/// * Has no scroll bars
42/// * Has no limits on the scrolling
43#[derive(Clone, Debug)]
44#[must_use = "You should call .show()"]
45pub struct Scene {
46    zoom_range: Rangef,
47    max_inner_size: Vec2,
48}
49
50impl Default for Scene {
51    fn default() -> Self {
52        Self {
53            zoom_range: Rangef::new(f32::EPSILON, 1.0),
54            max_inner_size: Vec2::splat(1000.0),
55        }
56    }
57}
58
59impl Scene {
60    #[inline]
61    pub fn new() -> Self {
62        Default::default()
63    }
64
65    /// Set the allowed zoom range.
66    ///
67    /// The default zoom range is `0.0..=1.0`,
68    /// which mean you zan make things arbitrarily small, but you cannot zoom in past a `1:1` ratio.
69    ///
70    /// If you want to allow zooming in, you can set the zoom range to `0.0..=f32::INFINITY`.
71    /// Note that text rendering becomes blurry when you zoom in: <https://github.com/emilk/egui/issues/4813>.
72    #[inline]
73    pub fn zoom_range(mut self, zoom_range: impl Into<Rangef>) -> Self {
74        self.zoom_range = zoom_range.into();
75        self
76    }
77
78    /// Set the maximum size of the inner [`Ui`] that will be created.
79    #[inline]
80    pub fn max_inner_size(mut self, max_inner_size: impl Into<Vec2>) -> Self {
81        self.max_inner_size = max_inner_size.into();
82        self
83    }
84
85    /// `scene_rect` contains the view bounds of the inner [`Ui`].
86    ///
87    /// `scene_rect` will be mutated by any panning/zooming done by the user.
88    /// If `scene_rect` is somehow invalid (e.g. `Rect::ZERO`),
89    /// then it will be reset to the inner rect of the inner ui.
90    ///
91    /// You need to store the `scene_rect` in your state between frames.
92    pub fn show<R>(
93        &self,
94        parent_ui: &mut Ui,
95        scene_rect: &mut Rect,
96        add_contents: impl FnOnce(&mut Ui) -> R,
97    ) -> InnerResponse<R> {
98        let (outer_rect, _outer_response) =
99            parent_ui.allocate_exact_size(parent_ui.available_size_before_wrap(), Sense::hover());
100
101        let mut to_global = fit_to_rect_in_scene(outer_rect, *scene_rect, self.zoom_range);
102
103        let scene_rect_was_good =
104            to_global.is_valid() && scene_rect.is_finite() && scene_rect.size() != Vec2::ZERO;
105
106        let mut inner_rect = *scene_rect;
107
108        let ret = self.show_global_transform(parent_ui, outer_rect, &mut to_global, |ui| {
109            let r = add_contents(ui);
110            inner_rect = ui.min_rect();
111            r
112        });
113
114        if ret.response.changed() {
115            // Only update if changed, both to avoid numeric drift,
116            // and to avoid expanding the scene rect unnecessarily.
117            *scene_rect = to_global.inverse() * outer_rect;
118        }
119
120        if !scene_rect_was_good {
121            // Auto-reset if the trsnsformation goes bad somehow (or started bad).
122            *scene_rect = inner_rect;
123        }
124
125        ret
126    }
127
128    fn show_global_transform<R>(
129        &self,
130        parent_ui: &mut Ui,
131        outer_rect: Rect,
132        to_global: &mut TSTransform,
133        add_contents: impl FnOnce(&mut Ui) -> R,
134    ) -> InnerResponse<R> {
135        // Create a new egui paint layer, where we can draw our contents:
136        let scene_layer_id = LayerId::new(
137            parent_ui.layer_id().order,
138            parent_ui.id().with("scene_area"),
139        );
140
141        // Put the layer directly on-top of the main layer of the ui:
142        parent_ui
143            .ctx()
144            .set_sublayer(parent_ui.layer_id(), scene_layer_id);
145
146        let mut local_ui = parent_ui.new_child(
147            UiBuilder::new()
148                .layer_id(scene_layer_id)
149                .max_rect(Rect::from_min_size(Pos2::ZERO, self.max_inner_size))
150                .sense(Sense::click_and_drag()),
151        );
152
153        let mut pan_response = local_ui.response();
154
155        // Update the `to_global` transform based on use interaction:
156        self.register_pan_and_zoom(&local_ui, &mut pan_response, to_global);
157
158        // Set a correct global clip rect:
159        local_ui.set_clip_rect(to_global.inverse() * outer_rect);
160
161        // Add the actual contents to the area:
162        let ret = add_contents(&mut local_ui);
163
164        // This ensures we catch clicks/drags/pans anywhere on the background.
165        local_ui.force_set_min_rect((to_global.inverse() * outer_rect).round_ui());
166
167        // Tell egui to apply the transform on the layer:
168        local_ui
169            .ctx()
170            .set_transform_layer(scene_layer_id, *to_global);
171
172        InnerResponse {
173            response: pan_response,
174            inner: ret,
175        }
176    }
177
178    /// Helper function to handle pan and zoom interactions on a response.
179    pub fn register_pan_and_zoom(&self, ui: &Ui, resp: &mut Response, to_global: &mut TSTransform) {
180        if resp.dragged() {
181            to_global.translation += to_global.scaling * resp.drag_delta();
182            resp.mark_changed();
183        }
184
185        if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) {
186            if resp.contains_pointer() {
187                let pointer_in_scene = to_global.inverse() * mouse_pos;
188                let zoom_delta = ui.ctx().input(|i| i.zoom_delta());
189                let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta);
190
191                // Most of the time we can return early. This is also important to
192                // avoid `ui_from_scene` to change slightly due to floating point errors.
193                if zoom_delta == 1.0 && pan_delta == Vec2::ZERO {
194                    return;
195                }
196
197                if zoom_delta != 1.0 {
198                    // Zoom in on pointer, but only if we are not zoomed in or out too far.
199                    let zoom_delta = zoom_delta.clamp(
200                        self.zoom_range.min / to_global.scaling,
201                        self.zoom_range.max / to_global.scaling,
202                    );
203
204                    *to_global = *to_global
205                        * TSTransform::from_translation(pointer_in_scene.to_vec2())
206                        * TSTransform::from_scaling(zoom_delta)
207                        * TSTransform::from_translation(-pointer_in_scene.to_vec2());
208
209                    // Clamp to exact zoom range.
210                    to_global.scaling = self.zoom_range.clamp(to_global.scaling);
211                }
212
213                // Pan:
214                *to_global = TSTransform::from_translation(pan_delta) * *to_global;
215                resp.mark_changed();
216            }
217        }
218    }
219}