Skip to main content

teksilo_scene/view/
camera_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Camera controls for [`SceneView`]: pan, zoom, rotation, viewport queries,
5//! and fit-to-content helpers.
6//!
7//! Every mutating method in this file operates on `&self` via `Signal::set` /
8//! `Signal::animate_to`, so a handler or clone of the view can drive the
9//! camera without `with_widget_mut`. The view transform is composed in
10//! [`compose_view`] from four independent
11//! `Signal<f32>` values (`pan_x`, `pan_y`, `zoom`, `rotation`) so each axis
12//! can animate with its own easing and epsilon. Reactive viewport queries
13//! ([`viewport_in_scene_signal`](SceneView::viewport_in_scene_signal)) expose
14//! the visible scene region as a `Signal<Rect>` suitable for driving a
15//! [`SceneMinimap`](crate::minimap::SceneMinimap) or lazy-loading logic.
16
17use super::*;
18use teksilo_core::event::{ScrollAlign, ScrollMotion};
19
20/// The camera, as a bundle of shared handles that outlives the `&self` that
21/// built it.
22///
23/// [`SceneView::ensure_visible`] and friends are `&self` methods, which is
24/// enough for an app holding the view. A **handler** is not: a `HandlerSet`
25/// closure is built once and lives in the arena, long after the `&SceneView`
26/// that installed it is gone. The reveal arm on `on_scroll` has to move the
27/// camera from inside such a closure, so the camera has to be something a
28/// closure can own — the same shape [`TransformDriver`](super::transform::TransformDriver)
29/// takes, and for the same reason.
30///
31/// Every field is a handle into live state, so a `Camera` cloned at build time
32/// still honours a pan-axes policy or a pan-bounds clamp the app changed
33/// afterwards.
34#[derive(Clone)]
35pub(crate) struct Camera {
36    pub pan_x: Signal<f32>,
37    pub pan_y: Signal<f32>,
38    pub zoom: Signal<f32>,
39    pub rotation: Signal<f32>,
40    pub bounds_origin: Signal<Vec2>,
41    pub viewport: Signal<Size>,
42    pub pan_axes: Signal<crate::scene::PanAxes>,
43    pub scene_pan_bounds: Signal<Option<Rect>>,
44    pub view_pan_bounds: Signal<Option<Rect>>,
45    pub adopt_scene_size: bool,
46    pub anim_duration: Duration,
47}
48
49impl std::fmt::Debug for Camera {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("Camera")
52            .field("pan", &Vec2::new(self.pan_x.get(), self.pan_y.get()))
53            .field("zoom", &self.zoom.get())
54            .field("rotation", &self.rotation.get())
55            .finish()
56    }
57}
58
59impl Camera {
60    /// The scene → screen transform, composed exactly as
61    /// [`SceneView::view_transform`] composes it.
62    fn view_transform(&self) -> Transform2D {
63        let bo = self.bounds_origin.get();
64        compose_view(
65            Vec2::new(self.pan_x.get() + bo.x, self.pan_y.get() + bo.y),
66            self.zoom.get(),
67            self.rotation.get(),
68        )
69    }
70
71    /// The same gate [`SceneView::gate_pan_target`] applies: pan-axes policy,
72    /// then the intersection of the scene's and the view's pan bounds.
73    fn gate_pan_target(&self, target: Vec2) -> Vec2 {
74        let hold = Vec2::new(self.pan_x.get(), self.pan_y.get());
75        if self.adopt_scene_size {
76            return hold;
77        }
78        let after_axes = apply_pan_axes(target, hold, self.pan_axes.get());
79        clamp_pan(
80            after_axes,
81            self.scene_pan_bounds.get(),
82            self.view_pan_bounds.get(),
83            self.viewport.get(),
84            self.zoom.get(),
85        )
86    }
87
88    /// Bring `scene_rect.expand(margin)` into view, and report the shift in
89    /// **scene** coordinates.
90    ///
91    /// # What the returned vector is, and why that space
92    ///
93    /// It is the amount the target appears to move *relative to the viewport*,
94    /// stated in the space the caller asked in. That is exactly what
95    /// [`teksilo_core::event::WidgetEvent::ScrollIntoView`]'s
96    /// `applied_scroll` back-channel is defined to carry: the reveal walk
97    /// subtracts it from the rectangle before projecting that rectangle out to
98    /// the next container, using the transform it read **before** dispatching.
99    /// Reporting a screen-space pan delta there would be wrong by a factor of
100    /// the zoom and, under rotation, by an angle.
101    ///
102    /// It is computed from the transform either side of the move rather than
103    /// derived by hand, so it stays exact if the composition ever changes:
104    /// `delta = rect.origin − T_before⁻¹(T_after(rect.origin))`, which is the
105    /// unique vector satisfying "the old transform applied to the shifted rect
106    /// is where the new transform puts the unshifted one".
107    ///
108    /// `Vec2::ZERO` when nothing moved — the target already fits, the pan-axes
109    /// policy forbids it, a clamp ate it, or the viewport has no area yet.
110    pub fn reveal(
111        &self,
112        scene_rect: Rect,
113        margin: f32,
114        align: ScrollAlign,
115        motion: ScrollMotion,
116    ) -> Vec2 {
117        let viewport = self.viewport.get();
118        if viewport.width <= 0.0 || viewport.height <= 0.0 {
119            return Vec2::ZERO;
120        }
121        let before = self.view_transform();
122        let Some(inv) = before.inverse() else {
123            return Vec2::ZERO;
124        };
125        // Visible scene region under the *current* view transform. Zoom is not
126        // touched: the correction is a translation in scene space.
127        let bo = self.bounds_origin.get();
128        let visible = inv.apply_rect(Rect::new(bo.x, bo.y, viewport.width, viewport.height));
129        let target = scene_rect.expand(margin);
130
131        // Per-axis. Horizontal is always minimal — a vertical fraction has no
132        // horizontal meaning, and yanking a horizontally-scrolled canvas
133        // sideways to honour one is the behaviour `ScrollAlign` documents
134        // avoiding.
135        let mut dx = 0.0;
136        if target.x < visible.x {
137            dx = target.x - visible.x;
138        } else if target.x + target.width > visible.x + visible.width {
139            dx = (target.x + target.width) - (visible.x + visible.width);
140        }
141        let dy = match align {
142            // Pin: put the target's top `f` of the way down the viewport,
143            // whether or not it is already visible. Unconditional by
144            // definition — a typewriter caret that only moved the camera once
145            // it fell off the edge would not be pinned to anything.
146            ScrollAlign::Fraction(f) => {
147                let f = f.clamp(0.0, 1.0);
148                let wanted = visible.y + (visible.height - target.height) * f;
149                target.y - wanted
150            }
151            ScrollAlign::Minimal => {
152                if target.y < visible.y {
153                    target.y - visible.y
154                } else if target.y + target.height > visible.y + visible.height {
155                    (target.y + target.height) - (visible.y + visible.height)
156                } else {
157                    0.0
158                }
159            }
160        };
161        if dx == 0.0 && dy == 0.0 {
162            return Vec2::ZERO;
163        }
164
165        // ∆scene > 0 means "show more of what is further along this axis",
166        // which is a *negative* screen-space pan: pan translates the scene at
167        // paint time, so revealing a region further right shifts the scene
168        // leftward.
169        let zoom = self.zoom.get();
170        let pan = Vec2::new(self.pan_x.get(), self.pan_y.get());
171        let wanted = Vec2::new(pan.x - dx * zoom, pan.y - dy * zoom);
172        let gated = self.gate_pan_target(wanted);
173        if gated == pan {
174            return Vec2::ZERO;
175        }
176        match motion {
177            ScrollMotion::Instant => {
178                self.pan_x.set(gated.x);
179                self.pan_y.set(gated.y);
180            }
181            // `try_`, and a snap when it is refused. A reveal is the one camera
182            // move an app never asked for — it arrives because a descendant
183            // moved its caret — so it must not be able to bring the app down
184            // over how the app chose to declare its own pan signals. Passing
185            // plain `Signal::new` handles to
186            // [`view_state`](SceneView::view_state) is supported and
187            // documented; before this route existed the only thing that
188            // animated them was an explicit `ensure_visible` call, which is the
189            // app's own doing.
190            ScrollMotion::Smooth => {
191                let x = self
192                    .pan_x
193                    .try_animate_to(gated.x, self.anim_duration, Easing::EaseOut);
194                let y = self
195                    .pan_y
196                    .try_animate_to(gated.y, self.anim_duration, Easing::EaseOut);
197                if x.is_err() || y.is_err() {
198                    self.pan_x.set(gated.x);
199                    self.pan_y.set(gated.y);
200                }
201            }
202        }
203
204        // Read the applied shift back out of the two transforms rather than
205        // returning the requested `(dx, dy)`: the gate above may have taken
206        // some or all of it, and an outer scroll container re-targeted by a
207        // delta that never happened scrolls to the wrong place.
208        let after = compose_view(
209            Vec2::new(gated.x + bo.x, gated.y + bo.y),
210            zoom,
211            self.rotation.get(),
212        );
213        let origin = Point::new(scene_rect.x, scene_rect.y);
214        let landed = inv.apply_point(after.apply_point(origin));
215        Vec2::new(origin.x - landed.x, origin.y - landed.y)
216    }
217}
218
219impl SceneView {
220    /// Read access to the underlying scene, as a borrow guard.
221    ///
222    /// Prefer the cloneable [`model`](Self::model) handle for multi-view
223    /// wiring and scene mutation (its methods are `&self`); this guard is the
224    /// single-view escape hatch for ad-hoc reads.
225    pub fn scene(&self) -> std::cell::Ref<'_, Scene> {
226        self.model.0.borrow()
227    }
228
229    /// Mutable access to the underlying scene, as a borrow guard.
230    ///
231    /// Single-view escape hatch. For multi-view, mutate through the shared
232    /// [`SceneModel`] handle ([`model`](Self::model)) — every
233    /// mutator is `&self`, so a handler holding a clone can drive the scene
234    /// directly (no `with_widget_mut` needed) and **all** views reconcile:
235    ///
236    /// ```
237    /// # use teksilo_scene::{Scene, SceneView};
238    /// # use teksilo_canvas::Rect;
239    /// # let view = SceneView::new(Scene::new());
240    /// # let card_data = "example payload";
241    /// # let rect = Rect::new(0.0, 0.0, 200.0, 120.0);
242    /// let model = view.model();          // cheap handle clone
243    /// model.add_widget_item(card_data, rect);   // every view rebuilds it
244    /// ```
245    ///
246    /// The view self-reconciles on every mutation: `add_widget_item` /
247    /// `add_item` materialise on the next rebuild, `remove` destroys the
248    /// orphaned arena widget and cleans its maps, `set_payload` rebuilds an
249    /// item's widget, and **both** the visual tree and the *separate* AccessKit
250    /// tree re-walk (geometry, reparents, and pure-a11y mutations all reach
251    /// assistive tech — `build()` requests an AT re-walk, since a relayout no
252    /// longer does so on its own).
253    pub fn scene_mut(&mut self) -> std::cell::RefMut<'_, Scene> {
254        self.model.0.borrow_mut()
255    }
256
257    /// The `WidgetId` an item was materialised as, if known.
258    pub fn widget_id_for(&self, id: ItemId) -> Option<WidgetId> {
259        self.materialized.get(&id).copied()
260    }
261
262    /// Current pan offset (logical pixels).
263    pub fn pan(&self) -> Vec2 {
264        Vec2::new(self.pan_x.get(), self.pan_y.get())
265    }
266
267    /// Current zoom factor.
268    pub fn zoom(&self) -> f32 {
269        self.zoom.get()
270    }
271
272    /// Current rotation in radians.
273    pub fn rotation(&self) -> f32 {
274        self.rotation.get()
275    }
276
277    /// In-flight animation target for the X pan signal, or `None`
278    /// if the signal is at rest. Useful for tests that want to
279    /// observe a tween before it lands without spinning the
280    /// scheduler.
281    pub fn pan_x_animation_target(&self) -> Option<f32> {
282        self.pan_x.animation_target()
283    }
284
285    /// In-flight animation target for the Y pan signal.
286    pub fn pan_y_animation_target(&self) -> Option<f32> {
287        self.pan_y.animation_target()
288    }
289
290    /// In-flight animation target for the zoom signal.
291    pub fn zoom_animation_target(&self) -> Option<f32> {
292        self.zoom.animation_target()
293    }
294
295    /// The composed view transform the render walker has on its
296    /// stack while painting this view's subtree. Includes the
297    /// `bounds.origin` offset captured during the last
298    /// `place_children` call, so this is the exact transform
299    /// applied to scene-coord points by the renderer.
300    pub fn view_transform(&self) -> Transform2D {
301        let pan = self.pan();
302        let bo = self.bounds_origin_signal.get();
303        compose_view(
304            Vec2::new(pan.x + bo.x, pan.y + bo.y),
305            self.zoom.get(),
306            self.rotation.get(),
307        )
308    }
309
310    /// The view's uniform zoom — the scale of the view transform's linear
311    /// part, the same number pointer dispatch passes to
312    /// [`ItemShape::contains`](crate::ItemShape::contains).
313    ///
314    /// It reaches exactly one thing: a **cosmetic** (device-pixel) stroke
315    /// band, whose width in scene coordinates shrinks as the view zooms in.
316    /// Computed as the hypotenuse of the transform's first column so a pure
317    /// rotation reports `1.0`.
318    pub fn view_scale(&self) -> f32 {
319        let m = self.view_transform().m;
320        m[0].hypot(m[1])
321    }
322
323    /// Project a point in **view space** (screen-pixel coords —
324    /// the same frame pointer events arrive in) into scene
325    /// coordinates. Inverse of [`map_from_scene`](Self::map_from_scene).
326    /// Returns the scene origin when the view transform is
327    /// degenerate (e.g. zoom = 0).
328    pub fn map_to_scene(&self, view_pt: Point) -> Point {
329        match self.view_transform().inverse() {
330            Some(inv) => inv.apply_point(view_pt),
331            None => Point::ZERO,
332        }
333    }
334
335    /// Project a point in **scene coords** to view space (screen
336    /// pixels). Inverse of [`map_to_scene`](Self::map_to_scene).
337    pub fn map_from_scene(&self, scene_pt: Point) -> Point {
338        self.view_transform().apply_point(scene_pt)
339    }
340
341    /// Project a rectangle in view space into scene coordinates.
342    /// Returns the AABB of the four projected corners under
343    /// rotation. Empty rect when the view transform is degenerate.
344    pub fn map_rect_to_scene(&self, view_rect: Rect) -> Rect {
345        match self.view_transform().inverse() {
346            Some(inv) => inv.apply_rect(view_rect),
347            None => Rect::ZERO,
348        }
349    }
350
351    /// Project a rectangle in scene coords into view space.
352    pub fn map_rect_from_scene(&self, scene_rect: Rect) -> Rect {
353        self.view_transform().apply_rect(scene_rect)
354    }
355
356    /// Reactive signal of the **visible scene region** — the
357    /// portion of scene space currently inside the SceneView's
358    /// viewport. Fires whenever pan / zoom / rotation /
359    /// bounds_origin / viewport-size changes.
360    ///
361    /// Use to drive a minimap viewport indicator, lazy-load only
362    /// the visible scene region, or implement "scroll into view"
363    /// guards. The value is the AABB of the viewport rectangle
364    /// projected through `view_transform.inverse()`.
365    pub fn viewport_in_scene_signal(&self) -> Signal<Rect> {
366        let xform_sig = self.view_transform_signal.clone();
367        let vp_sig = self.last_viewport.clone();
368        let bo_sig = self.bounds_origin_signal.clone();
369        xform_sig
370            .zip(&vp_sig)
371            .zip(&bo_sig)
372            .map_coalesced(|((xform, vp), bo)| {
373                let screen_rect = Rect::new(bo.x, bo.y, vp.width, vp.height);
374                match xform.inverse() {
375                    Some(inv) => inv.apply_rect(screen_rect),
376                    None => Rect::ZERO,
377                }
378            })
379    }
380
381    /// Reactive signal of the SceneView's resolved viewport size.
382    /// Fires whenever `layout_response` resolves a new size that
383    /// differs from the previous.
384    pub fn viewport_size_signal(&self) -> Signal<Size> {
385        self.last_viewport.clone()
386    }
387
388    /// Animate pan to `target` over `duration`. Bounded by
389    /// `Easing::EaseOut`. Honours `prefers-reduced-motion` only
390    /// indirectly: the scheduler pauses animation on window-inactive
391    /// and the test seam allows snapping. For an explicit snap, call
392    /// [`SceneView::set_pan`].
393    pub fn pan_to(&self, target: Vec2, duration: Duration) {
394        let target = self.gate_pan_target(target);
395        self.pan_x.animate_to(target.x, duration, Easing::EaseOut);
396        self.pan_y.animate_to(target.y, duration, Easing::EaseOut);
397    }
398
399    /// Snap pan to `target` without animation. Gated by the scene's
400    /// [`PanAxes`](crate::scene::PanAxes) policy.
401    pub fn set_pan(&self, target: Vec2) {
402        let target = self.gate_pan_target(target);
403        self.pan_x.set(target.x);
404        self.pan_y.set(target.y);
405    }
406
407    /// Animate zoom to `target` over `duration`, clamped to
408    /// `[min_zoom, max_zoom]`. No-op when the scene declares
409    /// [`Scene::zoomable(false)`](crate::Scene::zoomable).
410    pub fn zoom_to(&self, target: f32, duration: Duration) {
411        if !self.scene().is_zoomable() || self.adopt_scene_size {
412            return;
413        }
414        let clamped = self.gate_zoom_target(target);
415        self.zoom.animate_to(clamped, duration, Easing::EaseOut);
416    }
417
418    /// Snap zoom to `target` without animation, clamped. No-op when
419    /// the scene declares zoom disabled.
420    pub fn set_zoom(&self, target: f32) {
421        if !self.scene().is_zoomable() || self.adopt_scene_size {
422            return;
423        }
424        let clamped = self.gate_zoom_target(target);
425        self.zoom.set(clamped);
426    }
427
428    /// The camera as a cloneable handle — see [`Camera`].
429    pub(super) fn camera(&self) -> Camera {
430        Camera {
431            pan_x: self.pan_x.clone(),
432            pan_y: self.pan_y.clone(),
433            zoom: self.zoom.clone(),
434            rotation: self.rotation.clone(),
435            bounds_origin: self.bounds_origin_signal.clone(),
436            viewport: self.last_viewport.clone(),
437            pan_axes: self.scene().pan_axes_signal(),
438            scene_pan_bounds: self.scene().pan_bounds_signal(),
439            view_pan_bounds: self.pan_bounds_override.clone(),
440            adopt_scene_size: self.adopt_scene_size,
441            anim_duration: self.pan_anim_duration,
442        }
443    }
444
445    /// Pan (without changing zoom) so `scene_rect.expand(margin)`
446    /// fits inside the current visible scene region. If the
447    /// expanded target rect already fits, this is a no-op.
448    ///
449    /// Animated, over the view's pan-animation duration. For an instant jump,
450    /// or to pin the target at a fraction of the viewport rather than merely
451    /// reveal it, use
452    /// [`ensure_visible_aligned`](Self::ensure_visible_aligned).
453    ///
454    /// # Who calls this
455    ///
456    /// An app scrolling to a search hit or a newly created item calls it
457    /// directly. The framework calls it for every
458    /// [`teksilo_core::event::WidgetEvent::ScrollIntoView`]
459    /// that reaches the view — which is how a caret inside an embedded editor
460    /// moves the camera, since a `SceneView` clips its children and is
461    /// therefore on the reveal walk. (It is **not** called by focus traversal:
462    /// this doc used to claim it was, and nothing in the crate ever did.)
463    ///
464    /// Pan is gated by [`Scene::pan_axes`](crate::Scene::pan_axes):
465    /// if a scene declares `PanAxes::None`, this is a no-op; if it
466    /// declares a single axis, only that axis pans. Items can't be
467    /// scrolled into view if the policy doesn't permit panning
468    /// toward them.
469    pub fn ensure_visible(&self, scene_rect: Rect, margin: f32) {
470        self.ensure_visible_aligned(
471            scene_rect,
472            margin,
473            ScrollAlign::Minimal,
474            ScrollMotion::Smooth,
475        );
476    }
477
478    /// [`ensure_visible`](Self::ensure_visible) with an explicit alignment and
479    /// motion, reporting the shift it applied in **scene** coordinates.
480    ///
481    /// * [`ScrollAlign::Minimal`] reveals the target and does nothing when it
482    ///   is already visible; [`ScrollAlign::Fraction`] *pins* its top `f` of
483    ///   the way down the viewport whether or not it was visible.
484    /// * [`ScrollMotion::Instant`] snaps. That is what a caret chase wants:
485    ///   animating a reveal that re-fires on every keystroke is what produces
486    ///   the screen-bouncing typewriter modes are complained about for.
487    ///
488    /// The returned vector is `Vec2::ZERO` when nothing moved, and is what the
489    /// [`ScrollIntoView`](teksilo_core::event::WidgetEvent::ScrollIntoView)
490    /// `applied_scroll` back-channel carries: the shift the target appears to
491    /// make **relative to the viewport**, stated in the space the caller asked
492    /// in. The reveal walk subtracts it from the rectangle before projecting
493    /// that rectangle out to the next container, using the transform it read
494    /// *before* dispatching — so a screen-space pan delta there would be wrong
495    /// by a factor of the zoom, and under rotation by an angle.
496    pub fn ensure_visible_aligned(
497        &self,
498        scene_rect: Rect,
499        margin: f32,
500        align: ScrollAlign,
501        motion: ScrollMotion,
502    ) -> Vec2 {
503        self.camera().reveal(scene_rect, margin, align, motion)
504    }
505
506    /// Project `target` through the scene's pan-axes policy AND
507    /// clamp to the effective pan-bounds (intersection of Scene-
508    /// declared pan_bounds and view-level pan_bounds_override —
509    /// tightening-only). The orthogonal axis is held at its current
510    /// value when the policy excludes it; `PanAxes::None` (and
511    /// `adopt_scene_size`) holds both axes at their current pan.
512    fn gate_pan_target(&self, target: Vec2) -> Vec2 {
513        let hold = Vec2::new(self.pan_x.get(), self.pan_y.get());
514        if self.adopt_scene_size {
515            return hold;
516        }
517        let after_axes = apply_pan_axes(target, hold, self.scene().current_pan_axes());
518        clamp_pan(
519            after_axes,
520            self.scene().current_pan_bounds(),
521            self.pan_bounds_override.get(),
522            self.last_viewport.get(),
523            self.zoom.get(),
524        )
525    }
526
527    /// Clamp `zoom` to the effective zoom range (intersection of
528    /// Scene-declared `zoom_range` and view-level `zoom_range_override`).
529    /// `None` on either side means "no constraint from that side";
530    /// when both are `None` this is the identity. Tightening-only —
531    /// neither side can loosen what the other imposes.
532    pub(super) fn gate_zoom_target(&self, zoom: f32) -> f32 {
533        clamp_zoom(zoom, self.effective_zoom_range().as_ref())
534    }
535
536    /// Effective zoom range = intersect(Scene declared, view override).
537    fn effective_zoom_range(&self) -> Option<std::ops::RangeInclusive<f32>> {
538        intersect_zoom_range(
539            self.scene().current_zoom_range().as_ref(),
540            self.zoom_range_override.get().as_ref(),
541        )
542    }
543
544    /// Animate rotation to `target` over `duration` (radians).
545    pub fn rotate_to(&self, target_radians: f32, duration: Duration) {
546        self.rotation
547            .animate_to(target_radians, duration, Easing::EaseOut);
548    }
549
550    /// Snap rotation to `target` without animation.
551    pub fn set_rotation(&self, target_radians: f32) {
552        self.rotation.set(target_radians);
553    }
554
555    /// Snapshot the current pan / zoom / rotation as a
556    /// [`SceneViewState`](crate::SceneViewState). Designed for
557    /// persistence: store the snapshot in your settings layer on
558    /// app exit, restore it via [`restore_state`](Self::restore_state)
559    /// on next launch.
560    ///
561    /// The snapshot reflects the *current* signal values — if a
562    /// pan/zoom animation is in flight, the captured values are
563    /// the in-flight tween position, not the eventual target.
564    /// Apps that want to capture the target should query
565    /// [`pan_x_animation_target`](Self::pan_x_animation_target) /
566    /// friends manually.
567    pub fn state(&self) -> crate::SceneViewState {
568        crate::SceneViewState {
569            pan_x: self.pan_x.get(),
570            pan_y: self.pan_y.get(),
571            zoom: self.zoom.get(),
572            rotation: self.rotation.get(),
573        }
574    }
575
576    /// Restore a previously captured [`SceneViewState`](crate::SceneViewState).
577    /// Snaps each signal to the saved value (no animation —
578    /// pan/zoom/rotation jump to the persisted state immediately).
579    /// Zoom is clamped to `[min_zoom, max_zoom]`.
580    pub fn restore_state(&self, state: crate::SceneViewState) {
581        self.pan_x.set(state.pan_x);
582        self.pan_y.set(state.pan_y);
583        self.zoom.set(self.gate_zoom_target(state.zoom));
584        self.rotation.set(state.rotation);
585    }
586
587    /// Latest viewport size observed during layout. Useful for
588    /// imperative `fit_*` calls.
589    pub fn viewport_size(&self) -> Size {
590        self.last_viewport.get()
591    }
592
593    /// Compute the bounding rectangle (in scene coords) that encloses
594    /// every item in the scene. Returns `None` for an empty scene.
595    pub fn scene_content_bounds(&self) -> Option<Rect> {
596        let ids: Vec<ItemId> = self.scene().ids();
597        union_rects(ids.iter().filter_map(|id| self.scene().scene_rect(*id)))
598    }
599
600    /// Animate pan + zoom so the scene's content bounding box fits
601    /// the current viewport with a small margin. No-op for an empty
602    /// scene. Resets rotation to 0.
603    pub fn fit_to_content(&self) {
604        if let Some(content) = self.scene_content_bounds() {
605            self.fit_to_rect(content);
606        }
607    }
608
609    /// Animate pan + zoom so the union of the given items' bounds
610    /// fits the current viewport. Ids not currently in the scene
611    /// are skipped silently. No-op if `ids` is empty or all ids are
612    /// stale. Resets rotation to 0.
613    ///
614    /// Use this for "zoom to selection" / "frame this subset" UX.
615    pub fn fit_to_items(&self, ids: &[ItemId]) {
616        let union = union_rects(ids.iter().filter_map(|id| self.scene().scene_rect(*id)));
617        if let Some(rect) = union {
618            self.fit_to_rect(rect);
619        }
620    }
621
622    /// Animate pan + zoom so the bounds of the currently selected
623    /// items fit the viewport. No-op when nothing is selected.
624    /// Convenience for the common "F to focus selection" hotkey.
625    pub fn fit_to_selection(&self) {
626        let ids = self.selection.selected();
627        if !ids.is_empty() {
628            self.fit_to_items(&ids);
629        }
630    }
631
632    /// Internal: shared math for `fit_to_content` /
633    /// `fit_to_items` / `fit_to_selection`. Animates pan + zoom so
634    /// `rect` fits the current viewport with a margin, and resets
635    /// rotation to 0.
636    fn fit_to_rect(&self, rect: Rect) {
637        let viewport = self.last_viewport.get();
638        let margin = 24.0;
639        let avail_w = (viewport.width - margin * 2.0).max(1.0);
640        let avail_h = (viewport.height - margin * 2.0).max(1.0);
641        let raw_scale = (avail_w / rect.width.max(1.0)).min(avail_h / rect.height.max(1.0));
642        let scale = self.gate_zoom_target(raw_scale);
643        let center = rect.center();
644        let pan = Vec2::new(
645            viewport.width * 0.5 - scale * center.x,
646            viewport.height * 0.5 - scale * center.y,
647        );
648        self.zoom_to(scale, self.zoom_anim_duration);
649        self.rotate_to(0.0, self.zoom_anim_duration);
650        self.pan_to(pan, self.zoom_anim_duration);
651    }
652}