egui/containers/
scene.rs

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