|
| 1 | +use core::f32; |
| 2 | + |
| 3 | +use emath::{GuiRounding, Pos2}; |
| 4 | + |
| 5 | +use 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. |
| 14 | +fn 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()"] |
| 45 | +pub struct Scene { |
| 46 | + zoom_range: Rangef, |
| 47 | + max_inner_size: Vec2, |
| 48 | +} |
| 49 | + |
| 50 | +impl 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 | + |
| 59 | +impl 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 | +} |
0 commit comments