From 36cc337db45467bc47397af8983e449596a3e06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20G=C3=B6rtler?= Date: Thu, 19 Dec 2024 11:06:45 +0100 Subject: [PATCH 1/3] Initial implementation of `ZoomPanArea` --- crates/egui/src/containers/mod.rs | 1 + crates/egui/src/containers/zoom_pan_area.rs | 164 ++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 crates/egui/src/containers/zoom_pan_area.rs diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index e68e0def1b0..daae33f8153 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod resize; pub mod scroll_area; mod sides; pub(crate) mod window; +pub mod zoom_pan_area; pub use { area::{Area, AreaState}, diff --git a/crates/egui/src/containers/zoom_pan_area.rs b/crates/egui/src/containers/zoom_pan_area.rs new file mode 100644 index 00000000000..73758803ffc --- /dev/null +++ b/crates/egui/src/containers/zoom_pan_area.rs @@ -0,0 +1,164 @@ +//! A small, self-container pan-and-zoom area for [`egui`]. +//! +//! Throughout this module, we use the following conventions or naming the different spaces: +//! * `ui`-space: The _global_ `egui` space. +//! * `view`-space: The space where the pan-and-zoom area is drawn. +//! * `scene`-space: The space where the actual content is drawn. + +use crate::{emath::TSTransform, LayerId, Rect, Response, Sense, Ui, UiBuilder, Vec2}; + +/// Creates a transformation that fits a given scene rectangle into the available screen size. +/// +/// The resulting visual scene bounds can be larger, ue to letterboxing. +fn fit_to_rect_in_scene(rect_in_ui: Rect, rect_in_scene: Rect) -> TSTransform { + let available_size_in_ui = rect_in_ui.size(); + + // Compute the scale factor to fit the bounding rectangle into the available screen size. + let scale_x = available_size_in_ui.x / rect_in_scene.width(); + let scale_y = available_size_in_ui.y / rect_in_scene.height(); + + // Use the smaller of the two scales to ensure the whole rectangle fits on the screen. + let scale = scale_x.min(scale_y).min(1.0); + + // Compute the translation to center the bounding rect in the screen. + let center_screen = rect_in_ui.center(); + let center_scene = rect_in_scene.center().to_vec2(); + + // Set the transformation to scale and then translate to center. + TSTransform::from_translation(center_screen.to_vec2() - center_scene * scale) + * TSTransform::from_scaling(scale) +} + +#[derive(Clone, Debug)] +#[must_use = "You should call .show()"] +pub struct ZoomPanArea { + min_scaling: Option, + max_scaling: f32, + fit_rect: Option, +} + +impl Default for ZoomPanArea { + fn default() -> Self { + Self { + min_scaling: None, + max_scaling: 1.0, + fit_rect: None, + } + } +} + +impl ZoomPanArea { + pub fn new() -> Self { + Default::default() + } + + /// Provides a zoom-pan area for a given view. + /// + /// Will fill the entire `max_rect` of the `parent_ui`. + fn show_zoom_pan_area( + &self, + parent_ui: &mut Ui, + to_global: &mut TSTransform, + draw_contents: impl FnOnce(&mut Ui), + ) -> Response { + // Create a new egui paint layer, where we can draw our contents: + let zoom_pan_layer_id = LayerId::new( + parent_ui.layer_id().order, + parent_ui.id().with("zoom_pan_area"), + ); + + // Put the layer directly on-top of the main layer of the ui: + parent_ui + .ctx() + .set_sublayer(parent_ui.layer_id(), zoom_pan_layer_id); + + let global_view_bounds = parent_ui.max_rect(); + + // Optionally change the transformation so that a scene rect is + // contained in the view, potentially with letter boxing. + if let Some(rect_in_scene) = self.fit_rect { + *to_global = fit_to_rect_in_scene(global_view_bounds, rect_in_scene); + } + + let mut local_ui = parent_ui.new_child( + UiBuilder::new() + .layer_id(zoom_pan_layer_id) + .max_rect(to_global.inverse() * global_view_bounds) + .sense(Sense::click_and_drag()), + ); + local_ui.set_min_size(local_ui.max_rect().size()); // Allocate all available space + + // Set proper clip-rect so we can interact with the background: + local_ui.set_clip_rect(local_ui.max_rect()); + + let pan_response = local_ui.response(); + + // Update the `to_global` transform based on use interaction: + self.register_pan_and_zoom(&local_ui, &pan_response, to_global); + + // Update the clip-rect with the new transform, to avoid frame-delays + local_ui.set_clip_rect(to_global.inverse() * global_view_bounds); + + // Add the actual contents to the area: + draw_contents(&mut local_ui); + + // Tell egui to apply the transform on the layer: + local_ui + .ctx() + .set_transform_layer(zoom_pan_layer_id, *to_global); + + pan_response + } + + /// Helper function to handle pan and zoom interactions on a response. + fn register_pan_and_zoom(&self, ui: &Ui, resp: &Response, ui_from_scene: &mut TSTransform) { + if resp.dragged() { + ui_from_scene.translation += ui_from_scene.scaling * resp.drag_delta(); + } + + if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) { + if resp.contains_pointer() { + let pointer_in_scene = ui_from_scene.inverse() * mouse_pos; + let zoom_delta = ui.ctx().input(|i| i.zoom_delta()); + let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta); + + // Most of the time we can return early. This is also important to + // avoid `ui_from_scene` to change slightly due to floating point errors. + if zoom_delta == 1.0 && pan_delta == Vec2::ZERO { + return; + } + + // Zoom in on pointer, but only if we are not zoomed out too far. + if zoom_delta < 1.0 || ui_from_scene.scaling < 1.0 { + *ui_from_scene = *ui_from_scene + * TSTransform::from_translation(pointer_in_scene.to_vec2()) + * TSTransform::from_scaling(zoom_delta) + * TSTransform::from_translation(-pointer_in_scene.to_vec2()); + + // We clamp the resulting scaling to avoid zooming in/out too far. + if let Some(min_scaling) = self.min_scaling { + ui_from_scene.scaling = + ui_from_scene.scaling.clamp(min_scaling, self.max_scaling); + } else { + ui_from_scene.scaling = ui_from_scene.scaling.min(self.max_scaling); + } + } + + // Pan: + *ui_from_scene = TSTransform::from_translation(pan_delta) * *ui_from_scene; + } + } + } + + /// Show the [`ZoomPanArea`], and add the contents to the viewport. + /// + /// Mutates the `to_global` transformation to contain the new state, after potential panning and zooming. + pub fn show( + self, + ui: &mut Ui, + to_global: &mut TSTransform, + add_contents: impl FnOnce(&mut Ui), + ) -> Response { + self.show_zoom_pan_area(ui, to_global, add_contents) + } +} From 76c23dd8d8adb0590e64713eb4498dcc2ceb6f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20G=C3=B6rtler?= Date: Wed, 22 Jan 2025 15:59:18 +0100 Subject: [PATCH 2/3] Rename to scene --- crates/egui/src/containers/mod.rs | 2 +- .../containers/{zoom_pan_area.rs => scene.rs} | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) rename crates/egui/src/containers/{zoom_pan_area.rs => scene.rs} (93%) diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index daae33f8153..676156fbfa1 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -10,10 +10,10 @@ pub mod modal; pub mod panel; pub mod popup; pub(crate) mod resize; +pub mod scene; pub mod scroll_area; mod sides; pub(crate) mod window; -pub mod zoom_pan_area; pub use { area::{Area, AreaState}, diff --git a/crates/egui/src/containers/zoom_pan_area.rs b/crates/egui/src/containers/scene.rs similarity index 93% rename from crates/egui/src/containers/zoom_pan_area.rs rename to crates/egui/src/containers/scene.rs index 73758803ffc..66d279386bc 100644 --- a/crates/egui/src/containers/zoom_pan_area.rs +++ b/crates/egui/src/containers/scene.rs @@ -31,13 +31,13 @@ fn fit_to_rect_in_scene(rect_in_ui: Rect, rect_in_scene: Rect) -> TSTransform { #[derive(Clone, Debug)] #[must_use = "You should call .show()"] -pub struct ZoomPanArea { +pub struct Scene { min_scaling: Option, max_scaling: f32, fit_rect: Option, } -impl Default for ZoomPanArea { +impl Default for Scene { fn default() -> Self { Self { min_scaling: None, @@ -47,7 +47,7 @@ impl Default for ZoomPanArea { } } -impl ZoomPanArea { +impl Scene { pub fn new() -> Self { Default::default() } @@ -55,22 +55,22 @@ impl ZoomPanArea { /// Provides a zoom-pan area for a given view. /// /// Will fill the entire `max_rect` of the `parent_ui`. - fn show_zoom_pan_area( + fn show_scene( &self, parent_ui: &mut Ui, to_global: &mut TSTransform, draw_contents: impl FnOnce(&mut Ui), ) -> Response { // Create a new egui paint layer, where we can draw our contents: - let zoom_pan_layer_id = LayerId::new( + let scene_layer_id = LayerId::new( parent_ui.layer_id().order, - parent_ui.id().with("zoom_pan_area"), + parent_ui.id().with("scene_area"), ); // Put the layer directly on-top of the main layer of the ui: parent_ui .ctx() - .set_sublayer(parent_ui.layer_id(), zoom_pan_layer_id); + .set_sublayer(parent_ui.layer_id(), scene_layer_id); let global_view_bounds = parent_ui.max_rect(); @@ -82,7 +82,7 @@ impl ZoomPanArea { let mut local_ui = parent_ui.new_child( UiBuilder::new() - .layer_id(zoom_pan_layer_id) + .layer_id(scene_layer_id) .max_rect(to_global.inverse() * global_view_bounds) .sense(Sense::click_and_drag()), ); @@ -105,7 +105,7 @@ impl ZoomPanArea { // Tell egui to apply the transform on the layer: local_ui .ctx() - .set_transform_layer(zoom_pan_layer_id, *to_global); + .set_transform_layer(scene_layer_id, *to_global); pan_response } @@ -159,6 +159,6 @@ impl ZoomPanArea { to_global: &mut TSTransform, add_contents: impl FnOnce(&mut Ui), ) -> Response { - self.show_zoom_pan_area(ui, to_global, add_contents) + self.show_scene(ui, to_global, add_contents) } } From 70f804b360319dff0ebaab747562786280bd9150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20G=C3=B6rtler?= Date: Wed, 22 Jan 2025 17:54:14 +0100 Subject: [PATCH 3/3] WIP: working on converting the demo --- crates/egui/src/containers/scene.rs | 8 +- .../src/demo/demo_app_windows.rs | 2 +- crates/egui_demo_lib/src/demo/mod.rs | 2 +- crates/egui_demo_lib/src/demo/pan_zoom.rs | 145 ------------------ crates/egui_demo_lib/src/demo/scene.rs | 127 +++++++++++++++ 5 files changed, 133 insertions(+), 151 deletions(-) delete mode 100644 crates/egui_demo_lib/src/demo/pan_zoom.rs create mode 100644 crates/egui_demo_lib/src/demo/scene.rs diff --git a/crates/egui/src/containers/scene.rs b/crates/egui/src/containers/scene.rs index 66d279386bc..f7a142c9770 100644 --- a/crates/egui/src/containers/scene.rs +++ b/crates/egui/src/containers/scene.rs @@ -9,8 +9,8 @@ use crate::{emath::TSTransform, LayerId, Rect, Response, Sense, Ui, UiBuilder, V /// Creates a transformation that fits a given scene rectangle into the available screen size. /// -/// The resulting visual scene bounds can be larger, ue to letterboxing. -fn fit_to_rect_in_scene(rect_in_ui: Rect, rect_in_scene: Rect) -> TSTransform { +/// The resulting visual scene bounds can be larger, due to letterboxing. +pub fn fit_to_rect_in_scene(rect_in_ui: Rect, rect_in_scene: Rect) -> TSTransform { let available_size_in_ui = rect_in_ui.size(); // Compute the scale factor to fit the bounding rectangle into the available screen size. @@ -55,7 +55,7 @@ impl Scene { /// Provides a zoom-pan area for a given view. /// /// Will fill the entire `max_rect` of the `parent_ui`. - fn show_scene( + pub fn show_scene( &self, parent_ui: &mut Ui, to_global: &mut TSTransform, @@ -111,7 +111,7 @@ impl Scene { } /// Helper function to handle pan and zoom interactions on a response. - fn register_pan_and_zoom(&self, ui: &Ui, resp: &Response, ui_from_scene: &mut TSTransform) { + pub fn register_pan_and_zoom(&self, ui: &Ui, resp: &Response, ui_from_scene: &mut TSTransform) { if resp.dragged() { ui_from_scene.translation += ui_from_scene.scaling * resp.drag_delta(); } diff --git a/crates/egui_demo_lib/src/demo/demo_app_windows.rs b/crates/egui_demo_lib/src/demo/demo_app_windows.rs index b74eb7db3f4..dd2f8f0ecfd 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -77,7 +77,7 @@ impl Default for DemoGroups { Box::::default(), Box::::default(), Box::::default(), - Box::::default(), + Box::::default(), Box::::default(), Box::::default(), Box::::default(), diff --git a/crates/egui_demo_lib/src/demo/mod.rs b/crates/egui_demo_lib/src/demo/mod.rs index c00725fbd59..cb68a46fb0e 100644 --- a/crates/egui_demo_lib/src/demo/mod.rs +++ b/crates/egui_demo_lib/src/demo/mod.rs @@ -21,9 +21,9 @@ pub mod modals; pub mod multi_touch; pub mod paint_bezier; pub mod painting; -pub mod pan_zoom; pub mod panels; pub mod password; +pub mod scene; pub mod screenshot; pub mod scrolling; pub mod sliders; diff --git a/crates/egui_demo_lib/src/demo/pan_zoom.rs b/crates/egui_demo_lib/src/demo/pan_zoom.rs deleted file mode 100644 index e51b5b9d788..00000000000 --- a/crates/egui_demo_lib/src/demo/pan_zoom.rs +++ /dev/null @@ -1,145 +0,0 @@ -use egui::emath::TSTransform; -use egui::TextWrapMode; - -#[derive(Clone, Default, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct PanZoom { - transform: TSTransform, - drag_value: f32, -} - -impl Eq for PanZoom {} - -impl crate::Demo for PanZoom { - fn name(&self) -> &'static str { - "🔍 Pan Zoom" - } - - fn show(&mut self, ctx: &egui::Context, open: &mut bool) { - use crate::View as _; - let window = egui::Window::new("Pan Zoom") - .default_width(300.0) - .default_height(300.0) - .vscroll(false) - .open(open); - window.show(ctx, |ui| self.ui(ui)); - } -} - -impl crate::View for PanZoom { - fn ui(&mut self, ui: &mut egui::Ui) { - ui.label( - "Pan, zoom in, and zoom out with scrolling (see the plot demo for more instructions). \ - Double click on the background to reset.", - ); - ui.vertical_centered(|ui| { - ui.add(crate::egui_github_link_file!()); - }); - ui.separator(); - - let (id, rect) = ui.allocate_space(ui.available_size()); - let response = ui.interact(rect, id, egui::Sense::click_and_drag()); - // Allow dragging the background as well. - if response.dragged() { - self.transform.translation += response.drag_delta(); - } - - // Plot-like reset - if response.double_clicked() { - self.transform = TSTransform::default(); - } - - let transform = - TSTransform::from_translation(ui.min_rect().left_top().to_vec2()) * self.transform; - - if let Some(pointer) = ui.ctx().input(|i| i.pointer.hover_pos()) { - // Note: doesn't catch zooming / panning if a button in this PanZoom container is hovered. - if response.hovered() { - let pointer_in_layer = transform.inverse() * pointer; - let zoom_delta = ui.ctx().input(|i| i.zoom_delta()); - let pan_delta = ui.ctx().input(|i| i.smooth_scroll_delta); - - // Zoom in on pointer: - self.transform = self.transform - * TSTransform::from_translation(pointer_in_layer.to_vec2()) - * TSTransform::from_scaling(zoom_delta) - * TSTransform::from_translation(-pointer_in_layer.to_vec2()); - - // Pan: - self.transform = TSTransform::from_translation(pan_delta) * self.transform; - } - } - - for (i, (pos, callback)) in [ - ( - egui::Pos2::new(0.0, 0.0), - Box::new(|ui: &mut egui::Ui, _: &mut Self| { - ui.button("top left").on_hover_text("Normal tooltip") - }) as Box egui::Response>, - ), - ( - egui::Pos2::new(0.0, 120.0), - Box::new(|ui: &mut egui::Ui, _| { - ui.button("bottom left").on_hover_text("Normal tooltip") - }), - ), - ( - egui::Pos2::new(120.0, 120.0), - Box::new(|ui: &mut egui::Ui, _| { - ui.button("right bottom") - .on_hover_text_at_pointer("Tooltip at pointer") - }), - ), - ( - egui::Pos2::new(120.0, 0.0), - Box::new(|ui: &mut egui::Ui, _| { - ui.button("right top") - .on_hover_text_at_pointer("Tooltip at pointer") - }), - ), - ( - egui::Pos2::new(60.0, 60.0), - Box::new(|ui, state| { - use egui::epaint::{pos2, CircleShape, Color32, QuadraticBezierShape, Stroke}; - // Smiley face. - let painter = ui.painter(); - painter.add(CircleShape::filled(pos2(0.0, -10.0), 1.0, Color32::YELLOW)); - painter.add(CircleShape::filled(pos2(10.0, -10.0), 1.0, Color32::YELLOW)); - painter.add(QuadraticBezierShape::from_points_stroke( - [pos2(0.0, 0.0), pos2(5.0, 3.0), pos2(10.0, 0.0)], - false, - Color32::TRANSPARENT, - Stroke::new(1.0, Color32::YELLOW), - )); - - ui.add(egui::Slider::new(&mut state.drag_value, 0.0..=100.0).text("My value")) - }), - ), - ] - .into_iter() - .enumerate() - { - let window_layer = ui.layer_id(); - let id = egui::Area::new(id.with(("subarea", i))) - .default_pos(pos) - .order(egui::Order::Middle) - .constrain(false) - .show(ui.ctx(), |ui| { - ui.set_clip_rect(transform.inverse() * rect); - egui::Frame::default() - .rounding(egui::Rounding::same(4)) - .inner_margin(egui::Margin::same(8)) - .stroke(ui.ctx().style().visuals.window_stroke) - .fill(ui.style().visuals.panel_fill) - .show(ui, |ui| { - ui.style_mut().wrap_mode = Some(TextWrapMode::Extend); - callback(ui, self) - }); - }) - .response - .layer_id; - ui.ctx().set_transform_layer(id, transform); - ui.ctx().set_sublayer(window_layer, id); - } - } -} diff --git a/crates/egui_demo_lib/src/demo/scene.rs b/crates/egui_demo_lib/src/demo/scene.rs new file mode 100644 index 00000000000..dd79135cccb --- /dev/null +++ b/crates/egui_demo_lib/src/demo/scene.rs @@ -0,0 +1,127 @@ +use egui::emath::TSTransform; +use egui::scene::{fit_to_rect_in_scene, Scene}; +use egui::{Pos2, Rect, Sense, TextWrapMode, UiBuilder, Vec2}; + +#[derive(Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct SceneDemo { + transform: Option, + drag_value: f32, + scene_rect: Option, +} + +impl Eq for SceneDemo {} + +impl crate::Demo for SceneDemo { + fn name(&self) -> &'static str { + "🔍 Scene" + } + + fn show(&mut self, ctx: &egui::Context, open: &mut bool) { + use crate::View as _; + let window = egui::Window::new("Pan Zoom") + .default_width(300.0) + .default_height(300.0) + .vscroll(false) + .open(open); + window.show(ctx, |ui| self.ui(ui)); + } +} + +impl crate::View for SceneDemo { + fn ui(&mut self, ui: &mut egui::Ui) { + ui.label( + "Pan, zoom in, and zoom out with scrolling (see the plot demo for more instructions). \ + Double click on the background to reset.", + ); + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file!()); + }); + ui.separator(); + + let (_, rect) = ui.allocate_space(ui.available_size()); + + // TODO: Actually write that back + let mut state = self.clone(); + + let to_global = self.transform.get_or_insert_with(|| { + fit_to_rect_in_scene( + rect, + Rect::from_min_max(Pos2::new(0., 0.), Pos2::new(120., 120.)), + ) + }); + + let scene = Scene::new(); + scene.show_scene(ui, to_global, |ui| { + for (i, (pos, callback)) in [ + ( + egui::Pos2::new(0.0, 0.0), + Box::new(|ui: &mut egui::Ui, _: &mut Self| { + ui.button("top left").on_hover_text("Normal tooltip") + }) + as Box egui::Response>, + ), + ( + egui::Pos2::new(0.0, 120.0), + Box::new(|ui: &mut egui::Ui, _| { + ui.button("bottom left").on_hover_text("Normal tooltip") + }), + ), + ( + egui::Pos2::new(120.0, 120.0), + Box::new(|ui: &mut egui::Ui, _| { + ui.button("right bottom") + .on_hover_text_at_pointer("Tooltip at pointer") + }), + ), + ( + egui::Pos2::new(120.0, 0.0), + Box::new(|ui: &mut egui::Ui, _| { + ui.button("right top") + .on_hover_text_at_pointer("Tooltip at pointer") + }), + ), + ( + egui::Pos2::new(60.0, 60.0), + Box::new(|ui, state| { + use egui::epaint::{ + pos2, CircleShape, Color32, QuadraticBezierShape, Stroke, + }; + // Smiley face. + let painter = ui.painter(); + painter.add(CircleShape::filled(pos2(0.0, -10.0), 1.0, Color32::YELLOW)); + painter.add(CircleShape::filled(pos2(10.0, -10.0), 1.0, Color32::YELLOW)); + painter.add(QuadraticBezierShape::from_points_stroke( + [pos2(0.0, 0.0), pos2(5.0, 3.0), pos2(10.0, 0.0)], + false, + Color32::TRANSPARENT, + Stroke::new(1.0, Color32::YELLOW), + )); + + ui.add( + egui::Slider::new(&mut state.drag_value, 0.0..=100.0).text("My value"), + ) + }), + ), + ] + .into_iter() + .enumerate() + { + let builder = UiBuilder::new() + .max_rect(Rect::from_center_size(pos, Vec2::new(200., 200.))) + .sense(Sense::click()); + + let mut content_ui = ui.new_child(builder); + let content_resp = callback(&mut content_ui, &mut state); + state.scene_rect = Some( + state + .scene_rect + .get_or_insert(Rect::NOTHING) + .union(content_resp.rect), + ); + } + }); + + // scene.register_pan_and_zoom(ui, &resp, &mut self.transform); + } +}