diff --git a/crates/egui/src/containers/mod.rs b/crates/egui/src/containers/mod.rs index e68e0def1b0..abb44459845 100644 --- a/crates/egui/src/containers/mod.rs +++ b/crates/egui/src/containers/mod.rs @@ -10,6 +10,7 @@ pub mod modal; pub mod panel; pub mod popup; pub(crate) mod resize; +mod scene; pub mod scroll_area; mod sides; pub(crate) mod window; @@ -23,6 +24,7 @@ pub use { panel::{CentralPanel, SidePanel, TopBottomPanel}, popup::*, resize::Resize, + scene::Scene, scroll_area::ScrollArea, sides::Sides, window::Window, diff --git a/crates/egui/src/containers/scene.rs b/crates/egui/src/containers/scene.rs new file mode 100644 index 00000000000..5deeb01b600 --- /dev/null +++ b/crates/egui/src/containers/scene.rs @@ -0,0 +1,219 @@ +use core::f32; + +use emath::{GuiRounding, Pos2}; + +use crate::{ + emath::TSTransform, InnerResponse, LayerId, Rangef, 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, due to letterboxing. +/// +/// Returns the transformation from `scene` to `global` coordinates. +fn fit_to_rect_in_scene( + rect_in_global: Rect, + rect_in_scene: Rect, + zoom_range: Rangef, +) -> TSTransform { + // Compute the scale factor to fit the bounding rectangle into the available screen size: + let scale = rect_in_global.size() / rect_in_scene.size(); + + // Use the smaller of the two scales to ensure the whole rectangle fits on the screen: + let scale = scale.min_elem(); + + // Clamp scale to what is allowed + let scale = zoom_range.clamp(scale); + + // Compute the translation to center the bounding rect in the screen: + let center_in_global = rect_in_global.center().to_vec2(); + let center_scene = rect_in_scene.center().to_vec2(); + + // Set the transformation to scale and then translate to center. + TSTransform::from_translation(center_in_global - scale * center_scene) + * TSTransform::from_scaling(scale) +} + +/// A container that allows you to zoom and pan. +/// +/// This is similar to [`crate::ScrollArea`] but: +/// * Supports zooming +/// * Has no scroll bars +/// * Has no limits on the scrolling +#[derive(Clone, Debug)] +#[must_use = "You should call .show()"] +pub struct Scene { + zoom_range: Rangef, + max_inner_size: Vec2, +} + +impl Default for Scene { + fn default() -> Self { + Self { + zoom_range: Rangef::new(f32::EPSILON, 1.0), + max_inner_size: Vec2::splat(1000.0), + } + } +} + +impl Scene { + #[inline] + pub fn new() -> Self { + Default::default() + } + + /// Set the allowed zoom range. + /// + /// The default zoom range is `0.0..=1.0`, + /// which mean you zan make things arbitrarily small, but you cannot zoom in past a `1:1` ratio. + /// + /// If you want to allow zooming in, you can set the zoom range to `0.0..=f32::INFINITY`. + /// Note that text rendering becomes blurry when you zoom in: . + #[inline] + pub fn zoom_range(mut self, zoom_range: impl Into) -> Self { + self.zoom_range = zoom_range.into(); + self + } + + /// Set the maximum size of the inner [`Ui`] that will be created. + #[inline] + pub fn max_inner_size(mut self, max_inner_size: impl Into) -> Self { + self.max_inner_size = max_inner_size.into(); + self + } + + /// `scene_rect` contains the view bounds of the inner [`Ui`]. + /// + /// `scene_rect` will be mutated by any panning/zooming done by the user. + /// If `scene_rect` is somehow invalid (e.g. `Rect::ZERO`), + /// then it will be reset to the inner rect of the inner ui. + /// + /// You need to store the `scene_rect` in your state between frames. + pub fn show( + &self, + parent_ui: &mut Ui, + scene_rect: &mut Rect, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + let (outer_rect, _outer_response) = + parent_ui.allocate_exact_size(parent_ui.available_size_before_wrap(), Sense::hover()); + + let mut to_global = fit_to_rect_in_scene(outer_rect, *scene_rect, self.zoom_range); + + let scene_rect_was_good = + to_global.is_valid() && scene_rect.is_finite() && scene_rect.size() != Vec2::ZERO; + + let mut inner_rect = *scene_rect; + + let ret = self.show_global_transform(parent_ui, outer_rect, &mut to_global, |ui| { + let r = add_contents(ui); + inner_rect = ui.min_rect(); + r + }); + + if ret.response.changed() { + // Only update if changed, both to avoid numeric drift, + // and to avoid expanding the scene rect unnecessarily. + *scene_rect = to_global.inverse() * outer_rect; + } + + if !scene_rect_was_good { + // Auto-reset if the trsnsformation goes bad somehow (or started bad). + *scene_rect = inner_rect; + } + + ret + } + + fn show_global_transform( + &self, + parent_ui: &mut Ui, + outer_rect: Rect, + to_global: &mut TSTransform, + add_contents: impl FnOnce(&mut Ui) -> R, + ) -> InnerResponse { + // Create a new egui paint layer, where we can draw our contents: + let scene_layer_id = LayerId::new( + parent_ui.layer_id().order, + 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(), scene_layer_id); + + let mut local_ui = parent_ui.new_child( + UiBuilder::new() + .layer_id(scene_layer_id) + .max_rect(Rect::from_min_size(Pos2::ZERO, self.max_inner_size)) + .sense(Sense::click_and_drag()), + ); + + let mut pan_response = local_ui.response(); + + // Update the `to_global` transform based on use interaction: + self.register_pan_and_zoom(&local_ui, &mut pan_response, to_global); + + // Set a correct global clip rect: + local_ui.set_clip_rect(to_global.inverse() * outer_rect); + + // Add the actual contents to the area: + let ret = add_contents(&mut local_ui); + + // This ensures we catch clicks/drags/pans anywhere on the background. + local_ui.force_set_min_rect((to_global.inverse() * outer_rect).round_ui()); + + // Tell egui to apply the transform on the layer: + local_ui + .ctx() + .set_transform_layer(scene_layer_id, *to_global); + + InnerResponse { + response: pan_response, + inner: ret, + } + } + + /// Helper function to handle pan and zoom interactions on a response. + pub fn register_pan_and_zoom(&self, ui: &Ui, resp: &mut Response, to_global: &mut TSTransform) { + if resp.dragged() { + to_global.translation += to_global.scaling * resp.drag_delta(); + resp.mark_changed(); + } + + if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) { + if resp.contains_pointer() { + let pointer_in_scene = to_global.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; + } + + if zoom_delta != 1.0 { + // Zoom in on pointer, but only if we are not zoomed in or out too far. + let zoom_delta = zoom_delta.clamp( + self.zoom_range.min / to_global.scaling, + self.zoom_range.max / to_global.scaling, + ); + + *to_global = *to_global + * TSTransform::from_translation(pointer_in_scene.to_vec2()) + * TSTransform::from_scaling(zoom_delta) + * TSTransform::from_translation(-pointer_in_scene.to_vec2()); + + // Clamp to exact zoom range. + to_global.scaling = self.zoom_range.clamp(to_global.scaling); + } + + // Pan: + *to_global = TSTransform::from_translation(pan_delta) * *to_global; + resp.mark_changed(); + } + } + } +} diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 12702fb2f1b..3f6804212da 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -161,6 +161,9 @@ impl ScrollBarVisibility { /// ``` /// /// You can scroll to an element using [`crate::Response::scroll_to_me`], [`Ui::scroll_to_cursor`] and [`Ui::scroll_to_rect`]. +/// +/// ## See also +/// If you want to allow zooming, use [`crate::Scene`]. #[derive(Clone, Debug)] #[must_use = "You should call .show()"] pub struct ScrollArea { diff --git a/crates/egui/src/text_selection/accesskit_text.rs b/crates/egui/src/text_selection/accesskit_text.rs index d0c3869038d..dedbc79dc25 100644 --- a/crates/egui/src/text_selection/accesskit_text.rs +++ b/crates/egui/src/text_selection/accesskit_text.rs @@ -1,4 +1,6 @@ -use crate::{Context, Galley, Id, Pos2}; +use emath::TSTransform; + +use crate::{Context, Galley, Id}; use super::{text_cursor_state::is_word_char, CursorRange}; @@ -8,7 +10,7 @@ pub fn update_accesskit_for_text_widget( widget_id: Id, cursor_range: Option, role: accesskit::Role, - galley_pos: Pos2, + global_from_galley: TSTransform, galley: &Galley, ) { let parent_id = ctx.accesskit_node_builder(widget_id, |builder| { @@ -43,7 +45,7 @@ pub fn update_accesskit_for_text_widget( let row_id = parent_id.with(row_index); ctx.accesskit_node_builder(row_id, |builder| { builder.set_role(accesskit::Role::TextRun); - let rect = row.rect.translate(galley_pos.to_vec2()); + let rect = global_from_galley * row.rect; builder.set_bounds(accesskit::Rect { x0: rect.min.x.into(), y0: rect.min.y.into(), diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index ea5f3c9c63a..aa9f0986aa8 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use emath::TSTransform; + use crate::{ layers::ShapeIdx, text::CCursor, text_selection::CCursorRange, Context, CursorIcon, Event, Galley, Id, LayerId, Pos2, Rect, Response, Ui, @@ -25,9 +27,14 @@ struct WidgetTextCursor { } impl WidgetTextCursor { - fn new(widget_id: Id, cursor: impl Into, galley_pos: Pos2, galley: &Galley) -> Self { + fn new( + widget_id: Id, + cursor: impl Into, + global_from_galley: TSTransform, + galley: &Galley, + ) -> Self { let ccursor = cursor.into(); - let pos = pos_in_galley(galley_pos, galley, ccursor); + let pos = global_from_galley * pos_in_galley(galley, ccursor); Self { widget_id, ccursor, @@ -36,8 +43,8 @@ impl WidgetTextCursor { } } -fn pos_in_galley(galley_pos: Pos2, galley: &Galley, ccursor: CCursor) -> Pos2 { - galley_pos + galley.pos_from_ccursor(ccursor).center().to_vec2() +fn pos_in_galley(galley: &Galley, ccursor: CCursor) -> Pos2 { + galley.pos_from_ccursor(ccursor).center() } impl std::fmt::Debug for WidgetTextCursor { @@ -228,8 +235,7 @@ impl LabelSelectionState { self.selection = None; } - fn copy_text(&mut self, galley_pos: Pos2, galley: &Galley, cursor_range: &CursorRange) { - let new_galley_rect = Rect::from_min_size(galley_pos, galley.size()); + fn copy_text(&mut self, new_galley_rect: Rect, galley: &Galley, cursor_range: &CursorRange) { let new_text = selected_text(galley, cursor_range); if new_text.is_empty() { return; @@ -308,7 +314,7 @@ impl LabelSelectionState { &mut self, ui: &Ui, response: &Response, - galley_pos: Pos2, + global_from_galley: TSTransform, galley: &Galley, ) -> TextCursorState { let Some(selection) = &mut self.selection else { @@ -321,6 +327,8 @@ impl LabelSelectionState { return TextCursorState::default(); } + let galley_from_global = global_from_galley.inverse(); + let multi_widget_text_select = ui.style().interaction.multi_widget_text_select; let may_select_widget = @@ -328,7 +336,8 @@ impl LabelSelectionState { if self.is_dragging && may_select_widget { if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - let galley_rect = Rect::from_min_size(galley_pos, galley.size()); + let galley_rect = + global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size()); let galley_rect = galley_rect.intersect(ui.clip_rect()); let is_in_same_column = galley_rect @@ -342,7 +351,7 @@ impl LabelSelectionState { let new_primary = if response.contains_pointer() { // Dragging into this widget - easy case: - Some(galley.cursor_from_pos(pointer_pos - galley_pos)) + Some(galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2())) } else if is_in_same_column && !self.has_reached_primary && selection.primary.pos.y <= selection.secondary.pos.y @@ -376,7 +385,7 @@ impl LabelSelectionState { if let Some(new_primary) = new_primary { selection.primary = - WidgetTextCursor::new(response.id, new_primary, galley_pos, galley); + WidgetTextCursor::new(response.id, new_primary, global_from_galley, galley); // We don't want the latency of `drag_started`. let drag_started = ui.input(|i| i.pointer.any_pressed()); @@ -402,11 +411,12 @@ impl LabelSelectionState { let has_secondary = response.id == selection.secondary.widget_id; if has_primary { - selection.primary.pos = pos_in_galley(galley_pos, galley, selection.primary.ccursor); + selection.primary.pos = + global_from_galley * pos_in_galley(galley, selection.primary.ccursor); } if has_secondary { selection.secondary.pos = - pos_in_galley(galley_pos, galley, selection.secondary.ccursor); + global_from_galley * pos_in_galley(galley, selection.secondary.ccursor); } self.has_reached_primary |= has_primary; @@ -479,11 +489,21 @@ impl LabelSelectionState { &mut self, ui: &Ui, response: &Response, - galley_pos: Pos2, + galley_pos_in_layer: Pos2, galley: &mut Arc, ) -> Vec { let widget_id = response.id; + let global_from_layer = ui + .ctx() + .layer_transform_to_global(ui.layer_id()) + .unwrap_or_default(); + let layer_from_galley = TSTransform::from_translation(galley_pos_in_layer.to_vec2()); + let galley_from_layer = layer_from_galley.inverse(); + let layer_from_global = global_from_layer.inverse(); + let galley_from_global = galley_from_layer * layer_from_global; + let global_from_galley = global_from_layer * layer_from_galley; + if response.hovered() { ui.ctx().set_cursor_icon(CursorIcon::Text); } @@ -493,13 +513,14 @@ impl LabelSelectionState { let old_selection = self.selection; - let mut cursor_state = self.cursor_for(ui, response, galley_pos, galley); + let mut cursor_state = self.cursor_for(ui, response, global_from_galley, galley); let old_range = cursor_state.range(galley); if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { if response.contains_pointer() { - let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos); + let cursor_at_pointer = + galley.cursor_from_pos((galley_from_global * pointer_pos).to_vec2()); // This is where we handle start-of-drag and double-click-to-select. // Actual drag-to-select happens elsewhere. @@ -509,7 +530,7 @@ impl LabelSelectionState { } if let Some(mut cursor_range) = cursor_state.range(galley) { - let galley_rect = Rect::from_min_size(galley_pos, galley.size()); + let galley_rect = global_from_galley * Rect::from_min_size(Pos2::ZERO, galley.size()); self.selection_bbox_this_frame = self.selection_bbox_this_frame.union(galley_rect); if let Some(selection) = &self.selection { @@ -519,7 +540,7 @@ impl LabelSelectionState { } if got_copy_event(ui.ctx()) { - self.copy_text(galley_pos, galley, &cursor_range); + self.copy_text(galley_rect, galley, &cursor_range); } cursor_state.set_range(Some(cursor_range)); @@ -541,23 +562,32 @@ impl LabelSelectionState { if primary_changed || !ui.style().interaction.multi_widget_text_select { selection.primary = - WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley); + WidgetTextCursor::new(widget_id, range.primary, global_from_galley, galley); self.has_reached_primary = true; } if secondary_changed || !ui.style().interaction.multi_widget_text_select { - selection.secondary = - WidgetTextCursor::new(widget_id, range.secondary, galley_pos, galley); + selection.secondary = WidgetTextCursor::new( + widget_id, + range.secondary, + global_from_galley, + galley, + ); self.has_reached_secondary = true; } } else { // Start of a new selection self.selection = Some(CurrentSelection { layer_id: response.layer_id, - primary: WidgetTextCursor::new(widget_id, range.primary, galley_pos, galley), + primary: WidgetTextCursor::new( + widget_id, + range.primary, + global_from_galley, + galley, + ), secondary: WidgetTextCursor::new( widget_id, range.secondary, - galley_pos, + global_from_galley, galley, ), }); @@ -580,7 +610,7 @@ impl LabelSelectionState { // Scroll to keep primary cursor in view: let row_height = estimate_row_height(galley); let primary_cursor_rect = - cursor_rect(galley_pos, galley, &range.primary, row_height); + global_from_galley * cursor_rect(galley, &range.primary, row_height); ui.scroll_to_rect(primary_cursor_rect, None); } } @@ -606,7 +636,7 @@ impl LabelSelectionState { response.id, cursor_range, accesskit::Role::Label, - galley_pos, + global_from_galley, galley, ); diff --git a/crates/egui/src/text_selection/text_cursor_state.rs b/crates/egui/src/text_selection/text_cursor_state.rs index 61407353a85..ebc618b2c4b 100644 --- a/crates/egui/src/text_selection/text_cursor_state.rs +++ b/crates/egui/src/text_selection/text_cursor_state.rs @@ -5,7 +5,7 @@ use epaint::text::{ Galley, }; -use crate::{epaint, NumExt, Pos2, Rect, Response, Ui}; +use crate::{epaint, NumExt, Rect, Response, Ui}; use super::{CCursorRange, CursorRange}; @@ -335,14 +335,14 @@ pub fn slice_char_range(s: &str, char_range: std::ops::Range) -> &str { &s[start_byte..end_byte] } -/// The thin rectangle of one end of the selection, e.g. the primary cursor. -pub fn cursor_rect(galley_pos: Pos2, galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect { - let mut cursor_pos = galley - .pos_from_cursor(cursor) - .translate(galley_pos.to_vec2()); - cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); +/// The thin rectangle of one end of the selection, e.g. the primary cursor, in local galley coordinates. +pub fn cursor_rect(galley: &Galley, cursor: &Cursor, row_height: f32) -> Rect { + let mut cursor_pos = galley.pos_from_cursor(cursor); + // Handle completely empty galleys - cursor_pos = cursor_pos.expand(1.5); - // slightly above/below row + cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); + + cursor_pos = cursor_pos.expand(1.5); // slightly above/below row + cursor_pos } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 2619035a4b9..81dd6a448a1 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use emath::Rect; +use emath::{Rect, TSTransform}; use epaint::text::{cursor::CCursor, Galley, LayoutJob}; use crate::{ @@ -587,8 +587,8 @@ impl TextEdit<'_> { && ui.input(|i| i.pointer.is_moving()) { // text cursor preview: - let cursor_rect = - cursor_rect(rect.min, &galley, &cursor_at_pointer, row_height); + let cursor_rect = TSTransform::from_translation(rect.min.to_vec2()) + * cursor_rect(&galley, &cursor_at_pointer, row_height); text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect); } @@ -738,7 +738,8 @@ impl TextEdit<'_> { if has_focus { if let Some(cursor_range) = state.cursor.range(&galley) { let primary_cursor_rect = - cursor_rect(galley_pos, &galley, &cursor_range.primary, row_height); + cursor_rect(&galley, &cursor_range.primary, row_height) + .translate(galley_pos.to_vec2()); if response.changed() || selection_changed { // Scroll to keep primary cursor in view: @@ -837,7 +838,7 @@ impl TextEdit<'_> { id, cursor_range, role, - galley_pos, + TSTransform::from_translation(galley_pos.to_vec2()), &galley, ); } 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..c8b142cacd5 100644 --- a/crates/egui_demo_lib/src/demo/demo_app_windows.rs +++ b/crates/egui_demo_lib/src/demo/demo_app_windows.rs @@ -77,8 +77,8 @@ impl Default for DemoGroups { Box::::default(), 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..a7d268e1f87 --- /dev/null +++ b/crates/egui_demo_lib/src/demo/scene.rs @@ -0,0 +1,82 @@ +use egui::{Pos2, Rect, Scene, Vec2}; + +use super::widget_gallery; + +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct SceneDemo { + widget_gallery: widget_gallery::WidgetGallery, + scene_rect: Rect, +} + +impl Default for SceneDemo { + fn default() -> Self { + Self { + widget_gallery: Default::default(), + scene_rect: Rect::ZERO, // `egui::Scene` will initialize this to something valid + } + } +} + +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("Scene") + .default_width(300.0) + .default_height(300.0) + .scroll(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( + "You can pan by scrolling, and zoom using cmd-scroll. \ + Double click on the background to reset view.", + ); + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file!()); + }); + ui.separator(); + + ui.label(format!("Scene rect: {:#?}", &mut self.scene_rect)); + + ui.separator(); + + egui::Frame::group(ui.style()) + .inner_margin(0.0) + .show(ui, |ui| { + let scene = Scene::new() + .max_inner_size([350.0, 1000.0]) + .zoom_range(0.1..=2.0); + + let mut reset_view = false; + let mut inner_rect = Rect::NAN; + let response = scene + .show(ui, &mut self.scene_rect, |ui| { + reset_view = ui.button("Reset view").clicked(); + + ui.add_space(16.0); + + self.widget_gallery.ui(ui); + + ui.put( + Rect::from_min_size(Pos2::new(0.0, -64.0), Vec2::new(200.0, 16.0)), + egui::Label::new("You can put a widget anywhere").selectable(false), + ); + + inner_rect = ui.min_rect(); + }) + .response; + + if reset_view || response.double_clicked() { + self.scene_rect = inner_rect; + } + }); + } +} diff --git a/crates/egui_demo_lib/tests/snapshots/demos/Scene.png b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png new file mode 100644 index 00000000000..6f1e00fe1d2 --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/demos/Scene.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4eed8890c6d8fa6b97639197f5d1be79a72724a70470c5e5ae4b55e3447b9b88 +size 35561 diff --git a/crates/emath/src/ts_transform.rs b/crates/emath/src/ts_transform.rs index 4a761191bac..46c7e2a8e31 100644 --- a/crates/emath/src/ts_transform.rs +++ b/crates/emath/src/ts_transform.rs @@ -33,7 +33,7 @@ impl TSTransform { #[inline] /// Creates a new translation that first scales points around - /// `(0, 0)`, then translates them. + /// `(0, 0)`, then translates them. pub fn new(translation: Vec2, scaling: f32) -> Self { Self { translation, @@ -51,6 +51,11 @@ impl TSTransform { Self::new(Vec2::ZERO, scaling) } + /// Is this a valid, invertible transform? + pub fn is_valid(&self) -> bool { + self.scaling.is_finite() && self.translation.x.is_finite() && self.scaling != 0.0 + } + /// Inverts the transform. /// /// ```