Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add egui::Scene for panning/zooming a Ui #5505

Merged
merged 18 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/egui/src/containers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@ pub use {
panel::{CentralPanel, SidePanel, TopBottomPanel},
popup::*,
resize::Resize,
scene::Scene,
scroll_area::ScrollArea,
sides::Sides,
window::Window,
Expand Down
198 changes: 198 additions & 0 deletions crates/egui/src/containers/scene.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
use core::f32;

use emath::{GuiRounding, NumExt as _, 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.
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.
const MAX_SCALE: f32 = 1.0;
let scale = f32::min(scale_x, scale_y).at_most(MAX_SCALE);

// 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)
}

/// 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 maximum size of the inner [`Ui`] that will be created.
#[inline]
pub fn max_inner_size(mut self, max_inner_size: impl Into<Vec2>) -> 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<R>(
&self,
parent_ui: &mut Ui,
scene_rect: &mut Rect,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
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);

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<R>(
&self,
parent_ui: &mut Ui,
outer_rect: Rect,
to_global: &mut TSTransform,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
// 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;
}

// Zoom in on pointer, but only if we are not zoomed in or out too far.
if zoom_delta > 1.0 && to_global.scaling < self.zoom_range.max
|| zoom_delta < 1.0 && self.zoom_range.min < 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());

// We clamp the resulting scaling to avoid zooming in/out too far.
to_global.scaling = self.zoom_range.clamp(to_global.scaling);
}

// Pan:
*to_global = TSTransform::from_translation(pan_delta) * *to_global;
resp.mark_changed();
}
}
}
}
3 changes: 3 additions & 0 deletions crates/egui/src/containers/scroll_area.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions crates/egui/src/text_selection/accesskit_text.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -8,7 +10,7 @@ pub fn update_accesskit_for_text_widget(
widget_id: Id,
cursor_range: Option<CursorRange>,
role: accesskit::Role,
galley_pos: Pos2,
global_from_galley: TSTransform,
galley: &Galley,
) {
let parent_id = ctx.accesskit_node_builder(widget_id, |builder| {
Expand Down Expand Up @@ -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(),
Expand Down
Loading