agg_gui/widgets/scene.rs
1//! `Scene` — a pan/zoom container that hosts one child widget subtree in an
2//! infinite scrollable/zoomable canvas.
3//!
4//! # What it does
5//!
6//! `Scene` applies a translation + uniform-scale transform (a
7//! [`SceneTransform`]) to its content's painting **and** its pointer input, so
8//! the hosted widgets stay fully interactive while the user pans (drag on empty
9//! background, or middle-drag) and zooms (mouse wheel, anchored on the cursor).
10//! Double-clicking the empty background resets the view, matching egui's
11//! `Scene` container. The current visible region is exposed as a scene-space
12//! rect via [`Scene::scene_rect`] / [`Scene::with_scene_rect_cell`].
13//!
14//! # The content is a first-class framework child
15//!
16//! The hosted subtree lives in [`children`](crate::widget::Widget::children)
17//! like any other container's content, laid out in **scene space** (pinned at
18//! the origin). The pan/zoom is injected through the framework's
19//! [`child_transform`](crate::widget::Widget::child_transform) hook: painting,
20//! hit-testing, event dispatch, focus, and the inspector all map coordinates
21//! through that transform when they descend into the Scene, so the content is
22//! fully interactive *and* reachable by:
23//!
24//! * keyboard focus — Tab traversal and click-to-focus reach widgets inside the
25//! Scene, so text fields hosted here accept typing;
26//! * the inspector — hosted widgets appear in the tree at their on-screen
27//! (transformed) bounds.
28//!
29//! `Scene`'s own [`on_event`](crate::widget::Widget::on_event) handles only the
30//! background gestures (pan / zoom / double-click reset). It receives a pointer
31//! event exactly when no hosted child consumed it — the framework's leaf→root
32//! bubble delivers the press to the Scene only after the content declined it —
33//! so a press on empty canvas pans while a press on a hosted button clicks the
34//! button. While a pan is in progress the Scene claims the pointer
35//! exclusively (see [`claims_pointer_exclusively`]) so the drag keeps landing on
36//! the Scene regardless of what sits under the moving cursor.
37//!
38//! Relationship to other modules: mirrors the builder style of
39//! [`crate::widgets::ScrollView`]; the transform math lives in the
40//! [`transform`] submodule (unit-tested independently); the background-gesture
41//! handling lives in the [`events`] submodule.
42//!
43//! [`claims_pointer_exclusively`]: crate::widget::Widget::claims_pointer_exclusively
44//! [`on_event`]: crate::widget::Widget::on_event
45
46use std::cell::Cell;
47use std::rc::Rc;
48
49use web_time::Instant;
50
51use crate::draw_ctx::DrawCtx;
52use crate::event::{Event, EventResult};
53use crate::geometry::{Point, Rect, Size};
54use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
55use crate::widget::Widget;
56
57mod events;
58mod transform;
59
60pub use transform::SceneTransform;
61
62/// Default zoom range, matching egui's `Scene` (`0.1..=2.0`).
63pub const DEFAULT_ZOOM_RANGE: (f64, f64) = (0.1, 2.0);
64
65/// Double-click window (ms) for background-reset detection. 300-400 ms is
66/// the conventional range; egui's default double-click delay is 300 ms.
67const DBL_CLICK_MS: u128 = 400;
68
69/// Maximum pointer travel (logical px) for a press-release pair to still
70/// count as a *click* rather than a drag — matches egui's click tolerance.
71const MAX_CLICK_DIST: f64 = 6.0;
72
73/// Wheel-notch → zoom sensitivity. `zoom *= exp(delta_y * k)`, so positive
74/// `delta_y` (wheel forward / scroll up) zooms in, symmetric on the way out.
75const ZOOM_SENSITIVITY: f64 = 0.1;
76
77/// A pan/zoom canvas hosting a single content widget subtree.
78///
79/// See the [module docs](self) for the coordinate model and the keyboard-focus
80/// limitation.
81pub struct Scene {
82 bounds: Rect,
83 base: WidgetBase,
84
85 /// The hosted subtree as the Scene's single framework child, laid out in
86 /// scene space and pinned at the scene-space origin `(0, 0)`. The pan/zoom
87 /// is injected via [`Widget::child_transform`], so the framework maps
88 /// coordinates through it for paint, hit-test, dispatch, focus, and the
89 /// inspector.
90 children: Vec<Box<dyn Widget>>,
91
92 transform: SceneTransform,
93 zoom_range: (f64, f64),
94
95 /// Explicit size handed to `content.layout`. When `None`, the content is
96 /// laid out against the Scene's own available size.
97 content_size: Option<Size>,
98 /// Scene-space rect that "reset view" (and the initial fit) targets. When
99 /// `None`, the content's laid-out bounds are used.
100 default_scene_rect: Option<Rect>,
101
102 /// While `false`, every layout re-fits the view to the content so the
103 /// initial view is correct as the container settles to its final size.
104 /// The first pan/zoom sets it `true`, freezing the view under user control.
105 user_interacted: bool,
106
107 /// Optional cell that receives the visible scene rect each layout.
108 scene_rect_cell: Option<Rc<Cell<Rect>>>,
109 /// Optional cell polled each layout; when `true`, resets the view and
110 /// clears the flag. Lets an external "Reset view" button drive the Scene.
111 reset_cell: Option<Rc<Cell<bool>>>,
112
113 // ── interaction state ──
114 panning: bool,
115 pan_last: Point,
116 /// Where the current pan gesture's button went down — used to decide on
117 /// release whether the gesture was a *click* (no significant motion) or a
118 /// drag, for double-click-reset detection.
119 pan_press: Point,
120 /// `true` once the current pan gesture moved beyond the click tolerance.
121 pan_moved: bool,
122 /// `true` when the current pan gesture was started with the left button
123 /// (only left-button clicks participate in double-click reset).
124 pan_is_left: bool,
125 /// Pointer-press epoch (see [`crate::animation::pointer_press_epoch`])
126 /// captured when the current background press began — compared against
127 /// [`last_bg_click_epoch`](Self::last_bg_click_epoch) on release so a
128 /// double-click that straddles a hosted-child click (which the Scene never
129 /// sees, because the child consumes it) is not mistaken for two
130 /// consecutive background clicks.
131 pan_press_epoch: u64,
132 /// Completion time of the last genuine background left-click
133 /// (press + release without significant motion), for double-click reset.
134 last_bg_click: Option<Instant>,
135 /// Pointer-press epoch of that last background click. A second click only
136 /// completes a double-click when its press epoch is exactly one greater
137 /// (i.e. no other press happened in between).
138 last_bg_click_epoch: Option<u64>,
139}
140
141impl Scene {
142 /// Create a Scene hosting `content`, with the default egui zoom range.
143 pub fn new(content: Box<dyn Widget>) -> Self {
144 Self {
145 bounds: Rect::default(),
146 base: WidgetBase::new(),
147 children: vec![content],
148 transform: SceneTransform::identity(),
149 zoom_range: DEFAULT_ZOOM_RANGE,
150 content_size: None,
151 default_scene_rect: None,
152 user_interacted: false,
153 scene_rect_cell: None,
154 reset_cell: None,
155 panning: false,
156 pan_last: Point::ORIGIN,
157 pan_press: Point::ORIGIN,
158 pan_moved: false,
159 pan_is_left: false,
160 pan_press_epoch: 0,
161 last_bg_click: None,
162 last_bg_click_epoch: None,
163 }
164 }
165
166 // Invariant: `children` always holds exactly one element — the hosted
167 // content — set at construction and never drained. `children_mut()` is
168 // public trait surface, so a caller could in principle empty it; the
169 // library itself never does, and indexing `[0]` documents (and asserts)
170 // that contract.
171
172 /// The hosted content subtree (the Scene's single child).
173 pub(super) fn content(&self) -> &dyn Widget {
174 self.children[0].as_ref()
175 }
176
177 /// Mutable access to the hosted content subtree.
178 pub(super) fn content_mut(&mut self) -> &mut dyn Widget {
179 self.children[0].as_mut()
180 }
181
182 /// Set the allowed zoom range (min, max). Values are screen-px per scene
183 /// unit. Passing them reversed is tolerated.
184 pub fn with_zoom_range(mut self, min: f64, max: f64) -> Self {
185 self.zoom_range = (min, max);
186 self
187 }
188
189 /// Lay the content out against this fixed size instead of the Scene's
190 /// available size. Use when the content should occupy a definite scene-space
191 /// extent regardless of the container size.
192 pub fn with_content_size(mut self, size: Size) -> Self {
193 self.content_size = Some(size);
194 self
195 }
196
197 /// Scene-space rect that the initial fit and "reset view" target. When
198 /// unset, the content's laid-out bounds are fitted instead.
199 pub fn with_default_scene_rect(mut self, rect: Rect) -> Self {
200 self.default_scene_rect = Some(rect);
201 self
202 }
203
204 /// Publish the visible scene rect into `cell` on every layout.
205 pub fn with_scene_rect_cell(mut self, cell: Rc<Cell<Rect>>) -> Self {
206 self.scene_rect_cell = Some(cell);
207 self
208 }
209
210 /// Bind a cell that, when set to `true`, resets the view on the next layout.
211 pub fn with_reset_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
212 self.reset_cell = Some(cell);
213 self
214 }
215
216 // ── layout-property forwarding (mirrors other container widgets) ──
217
218 pub fn with_margin(mut self, m: Insets) -> Self {
219 self.base.margin = m;
220 self
221 }
222 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
223 self.base.h_anchor = h;
224 self
225 }
226 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
227 self.base.v_anchor = v;
228 self
229 }
230 pub fn with_min_size(mut self, s: Size) -> Self {
231 self.base.min_size = s;
232 self
233 }
234 pub fn with_max_size(mut self, s: Size) -> Self {
235 self.base.max_size = s;
236 self
237 }
238
239 /// The region of scene space currently visible in the Scene's bounds.
240 pub fn scene_rect(&self) -> Rect {
241 self.transform
242 .visible_scene_rect(Size::new(self.bounds.width, self.bounds.height))
243 }
244
245 /// Current pan/zoom transform (scene→screen).
246 pub fn transform(&self) -> SceneTransform {
247 self.transform
248 }
249
250 /// Reset the view to fit the default scene rect (or the content bounds)
251 /// centered in the container. Re-enables the auto-fit-until-interaction
252 /// behaviour so the view stays fitted as the container settles.
253 pub fn reset_view(&mut self) {
254 self.user_interacted = false;
255 self.fit_to_default();
256 self.publish_scene_rect();
257 crate::animation::request_draw();
258 }
259
260 /// Compute and apply a fit transform for the current bounds. No-op until
261 /// the container and content both have positive size.
262 fn fit_to_default(&mut self) {
263 let container = Size::new(self.bounds.width, self.bounds.height);
264 let cb = self.content().bounds();
265 let target = self
266 .default_scene_rect
267 .unwrap_or_else(|| Rect::new(0.0, 0.0, cb.width, cb.height));
268 if container.width > 0.0
269 && container.height > 0.0
270 && target.width > 0.0
271 && target.height > 0.0
272 {
273 self.transform = SceneTransform::fit(target, container, self.zoom_range);
274 }
275 }
276
277 fn publish_scene_rect(&self) {
278 if let Some(cell) = &self.scene_rect_cell {
279 cell.set(self.scene_rect());
280 }
281 }
282}
283
284impl Widget for Scene {
285 fn type_name(&self) -> &'static str {
286 "Scene"
287 }
288 fn bounds(&self) -> Rect {
289 self.bounds
290 }
291 fn set_bounds(&mut self, b: Rect) {
292 self.bounds = b;
293 }
294 fn children(&self) -> &[Box<dyn Widget>] {
295 &self.children
296 }
297 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
298 &mut self.children
299 }
300
301 /// Inject the pan/zoom as the framework-level child transform. Every
302 /// coordinate-mapping traversal (paint, hit-test, dispatch, inspector)
303 /// composes this when descending into the content, so hosted widgets are
304 /// painted, clicked, focused, and inspected at the right place.
305 fn child_transform(&self) -> Option<crate::TransAffine> {
306 Some(self.transform.to_affine())
307 }
308
309 /// While a background pan is in progress the Scene owns the pointer: the
310 /// drag must keep landing on the Scene even as the cursor sweeps over
311 /// hosted widgets, so hit-testing stops here until the gesture ends.
312 fn claims_pointer_exclusively(&self, _local_pos: Point) -> bool {
313 self.panning
314 }
315
316 fn margin(&self) -> Insets {
317 self.base.margin
318 }
319 fn widget_base(&self) -> Option<&WidgetBase> {
320 Some(&self.base)
321 }
322 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
323 Some(&mut self.base)
324 }
325 fn h_anchor(&self) -> HAnchor {
326 self.base.h_anchor
327 }
328 fn v_anchor(&self) -> VAnchor {
329 self.base.v_anchor
330 }
331 fn min_size(&self) -> Size {
332 self.base.min_size
333 }
334 fn max_size(&self) -> Size {
335 self.base.max_size
336 }
337
338 /// Smooth pan/zoom needs sub-pixel positioning; opt out of the crisp-UI
339 /// integer-snap default (see [`Widget::enforce_integer_bounds`]).
340 fn enforce_integer_bounds(&self) -> bool {
341 false
342 }
343
344 fn layout(&mut self, available: Size) -> Size {
345 // Poll the external reset trigger.
346 if let Some(cell) = &self.reset_cell {
347 if cell.get() {
348 cell.set(false);
349 self.user_interacted = false;
350 }
351 }
352
353 self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
354
355 // Lay out the content at its natural (or configured) size and pin it to
356 // the scene-space origin.
357 let content_avail = self.content_size.unwrap_or(available);
358 let sz = self.content_mut().layout(content_avail);
359 self.content_mut()
360 .set_bounds(Rect::new(0.0, 0.0, sz.width, sz.height));
361
362 // Keep the view fitted until the user takes control, so the initial
363 // framing is right even as the container settles to its final size.
364 if !self.user_interacted {
365 self.fit_to_default();
366 }
367
368 self.publish_scene_rect();
369 available
370 }
371
372 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
373 let v = ctx.visuals();
374 let w = self.bounds.width;
375 let h = self.bounds.height;
376
377 // Canvas background only. The hosted content is a framework child,
378 // painted after this returns: the framework clips it to
379 // `clip_children_rect` (the Scene's bounds, in screen space) and then
380 // applies the pan/zoom via `child_transform`, so it lands exactly where
381 // pointer/focus/inspector expect it.
382 ctx.set_fill_color(v.bg_color);
383 ctx.begin_path();
384 ctx.rect(0.0, 0.0, w, h);
385 ctx.fill();
386 }
387
388 fn on_event(&mut self, event: &Event) -> EventResult {
389 self.handle_event(event)
390 }
391
392 fn properties(&self) -> Vec<(&'static str, String)> {
393 let r = self.scene_rect();
394 vec![
395 ("zoom", format!("{:.3}", self.transform.zoom)),
396 (
397 "scene_rect",
398 format!(
399 "[{:.1}, {:.1}, {:.1}, {:.1}]",
400 r.x, r.y, r.width, r.height
401 ),
402 ),
403 (
404 "zoom_range",
405 format!("{:.2}..={:.2}", self.zoom_range.0, self.zoom_range.1),
406 ),
407 ]
408 }
409}