1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
use crate::canvas::view::SceneView;
use core::f32;
use egui::containers::DragPanButtons;
use egui::emath::{GuiRounding, TSTransform};
use egui::{
Event, Id, InnerResponse, LayerId, MouseWheelUnit, PointerButton, Pos2, Rangef, Rect, Response,
Sense, Ui, UiBuilder, Vec2,
};
/// A zoomable, pannable scene, after `egui::Scene`: the view is a centre and a
/// zoom kept by the caller, the zoom stops once the whole scene is visible,
/// and the view stays within [`pan_bounds`].
#[derive(Clone, Debug)]
#[must_use = "You should call .show()"]
pub(crate) struct SceneModded {
/// The scene's own extent. The zoom never goes below what shows all of it,
/// and that minimum is worked out from the rectangle the scene is actually
/// given — measuring the available space beforehand disagrees with it on
/// any pass where egui is still deciding the layout, and the scene is then
/// drawn larger than the canvas holding it.
scene_size: Vec2,
max_zoom: f32,
}
/// The buttons that pan by dragging: all but the primary one, which belongs
/// to the canvas.
fn drag_pan_buttons() -> DragPanButtons {
DragPanButtons::all() - DragPanButtons::PRIMARY
}
/// How far past the scene the view may travel, as a fraction of its size.
pub(crate) const PAN_MARGIN: f32 = 0.5;
/// The rectangle the view has to stay inside: the scene with [`PAN_MARGIN`] of
/// itself added on every side.
pub(crate) fn pan_bounds(scene: Rect) -> Rect {
scene.expand2(scene.size() * PAN_MARGIN)
}
/// `value` held within `radius` of `middle`. A negative radius leaves only
/// `middle`, so a view with no room to move sits in the middle of its bounds.
fn clamp_symmetric(value: f32, middle: f32, radius: f32) -> f32 {
value.clamp(middle - radius.max(0.0), middle + radius.max(0.0))
}
/// Whether the scroll in flight came from a mouse wheel rather than a
/// touchpad. egui smooths a wheel notch out over several frames, so the device
/// is remembered until the next wheel event says otherwise.
fn scroll_is_a_wheel(ui: &Ui) -> bool {
let remembered = Id::new("scene scroll device");
let seen = ui.input(|input| {
input.events.iter().rev().find_map(|event| match event {
Event::MouseWheel { unit, .. } => Some(*unit != MouseWheelUnit::Point),
_ => None,
})
});
match seen {
Some(wheel) => {
ui.ctx()
.memory_mut(|memory| memory.data.insert_temp(remembered, wheel));
wheel
}
None => ui
.ctx()
.memory(|memory| memory.data.get_temp(remembered).unwrap_or(false)),
}
}
/// The zoom a wheel scroll of `delta` asks for, on the curve egui uses for
/// ctrl+scroll.
fn wheel_zoom(delta: Vec2, speed: f32) -> f32 {
(speed * (delta.x + delta.y)).exp()
}
impl Default for SceneModded {
fn default() -> Self {
Self {
scene_size: Vec2::splat(1000.0),
max_zoom: 1.0,
}
}
}
impl SceneModded {
#[inline]
pub(crate) fn new() -> Self {
Default::default()
}
/// How big the scene is. Zooming out stops once all of it is visible, and
/// the ui inside is no bigger.
#[inline]
pub(crate) fn scene_size(mut self, scene_size: Vec2) -> Self {
self.scene_size = scene_size;
self
}
/// How far in the user may zoom.
#[inline]
pub(crate) fn max_zoom(mut self, max_zoom: f32) -> Self {
self.max_zoom = max_zoom;
self
}
/// `view` is where the canvas is looking, and is mutated by any panning or
/// zooming the user does. You need to store it between frames.
///
/// A view is a centre and a zoom rather than a visible rectangle: a
/// rectangle would have to be re-derived whenever the canvas changes size,
/// which turns every measuring pass into a jump.
pub(crate) fn show<R>(
&self,
parent_ui: &mut Ui,
view: &mut SceneView,
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 fitted = self.fitted_zoom(outer_rect.size());
let zoom_range = Rangef::new(fitted, self.max_zoom.max(fitted));
let zoom = self.zoom_of(outer_rect.size(), view);
let center = if view.center.is_finite() {
view.center
} else {
self.scene_rect().center()
};
let mut to_global =
TSTransform::from_translation(outer_rect.center().to_vec2() - zoom * center.to_vec2())
* TSTransform::from_scaling(zoom);
let ret = self.show_global_transform(
parent_ui,
outer_rect,
&mut to_global,
zoom_range,
add_contents,
);
view.center = to_global.inverse() * outer_rect.center();
view.zoom = (to_global.scaling > fitted).then_some(to_global.scaling);
ret
}
/// The scene's extent in scene units; it always starts at the origin.
pub(crate) fn scene_rect(&self) -> Rect {
Rect::from_min_size(Pos2::ZERO, self.scene_size)
}
/// The zoom that shows all of the scene in `canvas`.
///
/// It comes from the rectangle the scene is actually given, so the fit and
/// the canvas cannot disagree.
pub(crate) fn fitted_zoom(&self, canvas: Vec2) -> f32 {
(canvas / self.scene_size).min_elem()
}
/// The zoom a view is showing at. No zoom of its own means "show me all of
/// it", whatever size the canvas happens to be.
pub(crate) fn zoom_of(&self, canvas: Vec2, view: &SceneView) -> f32 {
let fitted = self.fitted_zoom(canvas);
view.zoom
.map_or(fitted, |zoom| zoom.clamp(fitted, self.max_zoom.max(fitted)))
}
/// What the view covers, in scene units.
pub(crate) fn visible(&self, canvas: Rect, view: &SceneView) -> Rect {
Rect::from_center_size(
view.center,
canvas.size() / self.zoom_of(canvas.size(), view),
)
}
/// Holds the view inside [`pan_bounds`].
///
/// The centre is clamped, not the pan: the limit is the same every frame,
/// so a drag stops at the edge instead of being pulled back next frame.
/// Once the view is wider than the bounds there is nowhere left to go and
/// the range collapses onto the middle, which is where it already is.
fn clamp_pan(&self, outer_rect: Rect, to_global: &mut TSTransform) {
let bounds = pan_bounds(self.scene_rect());
let visible = to_global.inverse() * outer_rect;
let center = visible.center();
let slack = (bounds.size() - visible.size()) * 0.5;
let clamped = Pos2::new(
clamp_symmetric(center.x, bounds.center().x, slack.x),
clamp_symmetric(center.y, bounds.center().y, slack.y),
);
to_global.translation += to_global.scaling * (center - clamped);
}
fn show_global_transform<R>(
&self,
parent_ui: &mut Ui,
outer_rect: Rect,
to_global: &mut TSTransform,
zoom_range: Rangef,
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(self.scene_rect())
.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, zoom_range);
// before anything is painted, so the view never shows a frame outside
// its bounds and never has to jump back
self.clamp_pan(outer_rect, to_global);
// Set a correct global clip rect:
local_ui.set_clip_rect(to_global.inverse() * outer_rect);
// Tell egui to apply the transform on the layer:
local_ui
.ctx()
.set_transform_layer(scene_layer_id, *to_global);
// Add the actual contents to the area:
let ret = add_contents(&mut local_ui);
// The ui covers everything visible, so clicks, drags and pans are
// caught anywhere on the background. egui::Scene forces its min rect
// here; growing it to the same rect has the same effect.
let min_rect = (to_global.inverse() * outer_rect).round_ui();
local_ui.expand_to_include_rect(min_rect);
InnerResponse {
response: pan_response,
inner: ret,
}
}
/// Pans and zooms `to_global` by what the pointer did on `resp`.
fn register_pan_and_zoom(
&self,
ui: &Ui,
resp: &mut Response,
to_global: &mut TSTransform,
zoom_range: Rangef,
) {
let dragged = drag_pan_buttons().iter().any(|button| match button {
DragPanButtons::SECONDARY => resp.dragged_by(PointerButton::Secondary),
DragPanButtons::MIDDLE => resp.dragged_by(PointerButton::Middle),
DragPanButtons::EXTRA_1 => resp.dragged_by(PointerButton::Extra1),
DragPanButtons::EXTRA_2 => resp.dragged_by(PointerButton::Extra2),
_ => false,
});
if 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())
&& 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);
// A wheel zooms: panning is the middle button's job, and a wheel
// only has the one axis to give. A touchpad goes on panning.
let (zoom_delta, pan_delta) = if scroll_is_a_wheel(ui) {
let speed = ui
.ctx()
.options(|options| options.input_options.scroll_zoom_speed);
(zoom_delta * wheel_zoom(pan_delta, speed), Vec2::ZERO)
} else {
(zoom_delta, pan_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(
zoom_range.min / to_global.scaling,
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 = zoom_range.clamp(to_global.scaling);
}
// Pan:
*to_global = TSTransform::from_translation(pan_delta) * *to_global;
resp.mark_changed();
}
}
}
#[cfg(test)]
mod tests;