Skip to main content

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}