Skip to content

Commit e8f351b

Browse files
grtlremilk
andauthored
Add egui::Scene for panning/zooming a Ui (emilk#5505)
This is similar to `ScrollArea`, but: * Supports zooming * Has no scroll bars * Has no limits on the scrolling ## TODO * [x] Automatic sizing of `Scene`s outer bounds * [x] Fix text selection in scenes * [x] Implement `fit_rect` * [x] Document / improve API --------- Co-authored-by: Emil Ernerfeldt <[email protected]>
1 parent 37c564b commit e8f351b

File tree

13 files changed

+391
-189
lines changed

13 files changed

+391
-189
lines changed

Diff for: crates/egui/src/containers/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod modal;
1010
pub mod panel;
1111
pub mod popup;
1212
pub(crate) mod resize;
13+
mod scene;
1314
pub mod scroll_area;
1415
mod sides;
1516
pub(crate) mod window;
@@ -23,6 +24,7 @@ pub use {
2324
panel::{CentralPanel, SidePanel, TopBottomPanel},
2425
popup::*,
2526
resize::Resize,
27+
scene::Scene,
2628
scroll_area::ScrollArea,
2729
sides::Sides,
2830
window::Window,

Diff for: crates/egui/src/containers/scene.rs

+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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+
}

Diff for: crates/egui/src/containers/scroll_area.rs

+3
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ impl ScrollBarVisibility {
161161
/// ```
162162
///
163163
/// You can scroll to an element using [`crate::Response::scroll_to_me`], [`Ui::scroll_to_cursor`] and [`Ui::scroll_to_rect`].
164+
///
165+
/// ## See also
166+
/// If you want to allow zooming, use [`crate::Scene`].
164167
#[derive(Clone, Debug)]
165168
#[must_use = "You should call .show()"]
166169
pub struct ScrollArea {

Diff for: crates/egui/src/text_selection/accesskit_text.rs

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::{Context, Galley, Id, Pos2};
1+
use emath::TSTransform;
2+
3+
use crate::{Context, Galley, Id};
24

35
use super::{text_cursor_state::is_word_char, CursorRange};
46

@@ -8,7 +10,7 @@ pub fn update_accesskit_for_text_widget(
810
widget_id: Id,
911
cursor_range: Option<CursorRange>,
1012
role: accesskit::Role,
11-
galley_pos: Pos2,
13+
global_from_galley: TSTransform,
1214
galley: &Galley,
1315
) {
1416
let parent_id = ctx.accesskit_node_builder(widget_id, |builder| {
@@ -43,7 +45,7 @@ pub fn update_accesskit_for_text_widget(
4345
let row_id = parent_id.with(row_index);
4446
ctx.accesskit_node_builder(row_id, |builder| {
4547
builder.set_role(accesskit::Role::TextRun);
46-
let rect = row.rect.translate(galley_pos.to_vec2());
48+
let rect = global_from_galley * row.rect;
4749
builder.set_bounds(accesskit::Rect {
4850
x0: rect.min.x.into(),
4951
y0: rect.min.y.into(),

0 commit comments

Comments
 (0)