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}