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::*;
18
19impl SceneView {
20    /// Read access to the underlying scene, as a borrow guard.
21    ///
22    /// Prefer the cloneable [`model`](Self::model) handle for multi-view
23    /// wiring and scene mutation (its methods are `&self`); this guard is the
24    /// single-view escape hatch for ad-hoc reads.
25    pub fn scene(&self) -> std::cell::Ref<'_, Scene> {
26        self.model.0.borrow()
27    }
28
29    /// Mutable access to the underlying scene, as a borrow guard.
30    ///
31    /// Single-view escape hatch. For multi-view, mutate through the shared
32    /// [`SceneModel`] handle ([`model`](Self::model)) — every
33    /// mutator is `&self`, so a handler holding a clone can drive the scene
34    /// directly (no `with_widget_mut` needed) and **all** views reconcile:
35    ///
36    /// ```
37    /// # use teksilo_scene::{Scene, SceneView};
38    /// # use teksilo_canvas::Rect;
39    /// # let view = SceneView::new(Scene::new());
40    /// # let card_data = "example payload";
41    /// # let rect = Rect::new(0.0, 0.0, 200.0, 120.0);
42    /// let model = view.model();          // cheap handle clone
43    /// model.add_widget_item(card_data, rect);   // every view rebuilds it
44    /// ```
45    ///
46    /// The view self-reconciles on every mutation: `add_widget_item` /
47    /// `add_item` materialise on the next rebuild, `remove` destroys the
48    /// orphaned arena widget and cleans its maps, `set_payload` rebuilds an
49    /// item's widget, and **both** the visual tree and the *separate* AccessKit
50    /// tree re-walk (geometry, reparents, and pure-a11y mutations all reach
51    /// assistive tech — `build()` requests an AT re-walk, since a relayout no
52    /// longer does so on its own).
53    pub fn scene_mut(&mut self) -> std::cell::RefMut<'_, Scene> {
54        self.model.0.borrow_mut()
55    }
56
57    /// The `WidgetId` an item was materialised as, if known.
58    pub fn widget_id_for(&self, id: ItemId) -> Option<WidgetId> {
59        self.materialized.get(&id).copied()
60    }
61
62    /// Current pan offset (logical pixels).
63    pub fn pan(&self) -> Vec2 {
64        Vec2::new(self.pan_x.get(), self.pan_y.get())
65    }
66
67    /// Current zoom factor.
68    pub fn zoom(&self) -> f32 {
69        self.zoom.get()
70    }
71
72    /// Current rotation in radians.
73    pub fn rotation(&self) -> f32 {
74        self.rotation.get()
75    }
76
77    /// In-flight animation target for the X pan signal, or `None`
78    /// if the signal is at rest. Useful for tests that want to
79    /// observe a tween before it lands without spinning the
80    /// scheduler.
81    pub fn pan_x_animation_target(&self) -> Option<f32> {
82        self.pan_x.animation_target()
83    }
84
85    /// In-flight animation target for the Y pan signal.
86    pub fn pan_y_animation_target(&self) -> Option<f32> {
87        self.pan_y.animation_target()
88    }
89
90    /// In-flight animation target for the zoom signal.
91    pub fn zoom_animation_target(&self) -> Option<f32> {
92        self.zoom.animation_target()
93    }
94
95    /// The composed view transform the render walker has on its
96    /// stack while painting this view's subtree. Includes the
97    /// `bounds.origin` offset captured during the last
98    /// `place_children` call, so this is the exact transform
99    /// applied to scene-coord points by the renderer.
100    pub fn view_transform(&self) -> Transform2D {
101        let pan = self.pan();
102        let bo = self.bounds_origin_signal.get();
103        compose_view(
104            Vec2::new(pan.x + bo.x, pan.y + bo.y),
105            self.zoom.get(),
106            self.rotation.get(),
107        )
108    }
109
110    /// Project a point in **view space** (screen-pixel coords —
111    /// the same frame pointer events arrive in) into scene
112    /// coordinates. Inverse of [`map_from_scene`](Self::map_from_scene).
113    /// Returns the scene origin when the view transform is
114    /// degenerate (e.g. zoom = 0).
115    pub fn map_to_scene(&self, view_pt: Point) -> Point {
116        match self.view_transform().inverse() {
117            Some(inv) => inv.apply_point(view_pt),
118            None => Point::ZERO,
119        }
120    }
121
122    /// Project a point in **scene coords** to view space (screen
123    /// pixels). Inverse of [`map_to_scene`](Self::map_to_scene).
124    pub fn map_from_scene(&self, scene_pt: Point) -> Point {
125        self.view_transform().apply_point(scene_pt)
126    }
127
128    /// Project a rectangle in view space into scene coordinates.
129    /// Returns the AABB of the four projected corners under
130    /// rotation. Empty rect when the view transform is degenerate.
131    pub fn map_rect_to_scene(&self, view_rect: Rect) -> Rect {
132        match self.view_transform().inverse() {
133            Some(inv) => inv.apply_rect(view_rect),
134            None => Rect::ZERO,
135        }
136    }
137
138    /// Project a rectangle in scene coords into view space.
139    pub fn map_rect_from_scene(&self, scene_rect: Rect) -> Rect {
140        self.view_transform().apply_rect(scene_rect)
141    }
142
143    /// Reactive signal of the **visible scene region** — the
144    /// portion of scene space currently inside the SceneView's
145    /// viewport. Fires whenever pan / zoom / rotation /
146    /// bounds_origin / viewport-size changes.
147    ///
148    /// Use to drive a minimap viewport indicator, lazy-load only
149    /// the visible scene region, or implement "scroll into view"
150    /// guards. The value is the AABB of the viewport rectangle
151    /// projected through `view_transform.inverse()`.
152    pub fn viewport_in_scene_signal(&self) -> Signal<Rect> {
153        let xform_sig = self.view_transform_signal.clone();
154        let vp_sig = self.last_viewport.clone();
155        let bo_sig = self.bounds_origin_signal.clone();
156        xform_sig
157            .zip(&vp_sig)
158            .zip(&bo_sig)
159            .map_coalesced(|((xform, vp), bo)| {
160                let screen_rect = Rect::new(bo.x, bo.y, vp.width, vp.height);
161                match xform.inverse() {
162                    Some(inv) => inv.apply_rect(screen_rect),
163                    None => Rect::ZERO,
164                }
165            })
166    }
167
168    /// Reactive signal of the SceneView's resolved viewport size.
169    /// Fires whenever `layout_response` resolves a new size that
170    /// differs from the previous.
171    pub fn viewport_size_signal(&self) -> Signal<Size> {
172        self.last_viewport.clone()
173    }
174
175    /// Animate pan to `target` over `duration`. Bounded by
176    /// `Easing::EaseOut`. Honours `prefers-reduced-motion` only
177    /// indirectly: the scheduler pauses animation on window-inactive
178    /// and the test seam allows snapping. For an explicit snap, call
179    /// [`SceneView::set_pan`].
180    pub fn pan_to(&self, target: Vec2, duration: Duration) {
181        let target = self.gate_pan_target(target);
182        self.pan_x.animate_to(target.x, duration, Easing::EaseOut);
183        self.pan_y.animate_to(target.y, duration, Easing::EaseOut);
184    }
185
186    /// Snap pan to `target` without animation. Gated by the scene's
187    /// [`PanAxes`](crate::scene::PanAxes) policy.
188    pub fn set_pan(&self, target: Vec2) {
189        let target = self.gate_pan_target(target);
190        self.pan_x.set(target.x);
191        self.pan_y.set(target.y);
192    }
193
194    /// Animate zoom to `target` over `duration`, clamped to
195    /// `[min_zoom, max_zoom]`. No-op when the scene declares
196    /// [`Scene::zoomable(false)`](crate::Scene::zoomable).
197    pub fn zoom_to(&self, target: f32, duration: Duration) {
198        if !self.scene().is_zoomable() || self.adopt_scene_size {
199            return;
200        }
201        let clamped = self.gate_zoom_target(target);
202        self.zoom.animate_to(clamped, duration, Easing::EaseOut);
203    }
204
205    /// Snap zoom to `target` without animation, clamped. No-op when
206    /// the scene declares zoom disabled.
207    pub fn set_zoom(&self, target: f32) {
208        if !self.scene().is_zoomable() || self.adopt_scene_size {
209            return;
210        }
211        let clamped = self.gate_zoom_target(target);
212        self.zoom.set(clamped);
213    }
214
215    /// Pan (without changing zoom) so `scene_rect.expand(margin)`
216    /// fits inside the current visible scene region. If the
217    /// expanded target rect already fits, this is a no-op.
218    ///
219    /// Pairs with focus traversal: when an off-viewport item gains
220    /// focus, the SceneView's default focus traversal calls this
221    /// automatically. Apps wanting to scroll a specific area into
222    /// view (e.g. on search-result selection) call it directly.
223    ///
224    /// Pan is gated by [`Scene::pan_axes`](crate::Scene::pan_axes):
225    /// if a scene declares `PanAxes::None`, this is a no-op; if it
226    /// declares a single axis, only that axis pans. Items can't be
227    /// scrolled into view if the policy doesn't permit panning
228    /// toward them.
229    pub fn ensure_visible(&self, scene_rect: Rect, margin: f32) {
230        let viewport = self.last_viewport.get();
231        if viewport.width <= 0.0 || viewport.height <= 0.0 {
232            return;
233        }
234        // Visible scene region under the *current* view transform.
235        // We don't change zoom — the per-axis correction is purely
236        // a translation in scene space, projected back through the
237        // current zoom (∆pan_screen = ∆target_scene * zoom).
238        let view_xform = self.view_transform();
239        let bo = self.bounds_origin_signal.get();
240        let viewport_screen = Rect::new(bo.x, bo.y, viewport.width, viewport.height);
241        let visible = match view_xform.inverse() {
242            Some(inv) => inv.apply_rect(viewport_screen),
243            None => return,
244        };
245        let target = scene_rect.expand(margin);
246
247        // Per-axis: shift only when the target lies outside the
248        // visible region. ∆scene > 0 means "scroll the world right",
249        // which translates to ∆pan_screen = -∆scene * zoom (pan is a
250        // translation applied to *the scene* at paint time, so to
251        // reveal a region further right we shift the scene leftward).
252        let zoom = self.zoom.get();
253        let mut dx = 0.0;
254        let mut dy = 0.0;
255        if target.x < visible.x {
256            dx = target.x - visible.x;
257        } else if target.x + target.width > visible.x + visible.width {
258            dx = (target.x + target.width) - (visible.x + visible.width);
259        }
260        if target.y < visible.y {
261            dy = target.y - visible.y;
262        } else if target.y + target.height > visible.y + visible.height {
263            dy = (target.y + target.height) - (visible.y + visible.height);
264        }
265        if dx == 0.0 && dy == 0.0 {
266            return;
267        }
268        let pan = self.pan();
269        let new_pan = Vec2::new(pan.x - dx * zoom, pan.y - dy * zoom);
270        // Animate the scroll instead of snapping — matches
271        // `pan_to`, `fit_to_rect`, and the surrounding gesture-driven
272        // animations. Reduced-motion handling is a follow-up
273        // (this call goes through `Signal::animate_to`, which is
274        // unconditional; `prefers-reduced-motion` consultation
275        // lives at the higher-level `ctx.animate()` builder).
276        self.pan_to(new_pan, self.pan_anim_duration);
277    }
278
279    /// Project `target` through the scene's pan-axes policy AND
280    /// clamp to the effective pan-bounds (intersection of Scene-
281    /// declared pan_bounds and view-level pan_bounds_override —
282    /// tightening-only). The orthogonal axis is held at its current
283    /// value when the policy excludes it; `PanAxes::None` (and
284    /// `adopt_scene_size`) holds both axes at their current pan.
285    fn gate_pan_target(&self, target: Vec2) -> Vec2 {
286        let hold = Vec2::new(self.pan_x.get(), self.pan_y.get());
287        if self.adopt_scene_size {
288            return hold;
289        }
290        let after_axes = apply_pan_axes(target, hold, self.scene().current_pan_axes());
291        clamp_pan(
292            after_axes,
293            self.scene().current_pan_bounds(),
294            self.pan_bounds_override.get(),
295            self.last_viewport.get(),
296            self.zoom.get(),
297        )
298    }
299
300    /// Clamp `zoom` to the effective zoom range (intersection of
301    /// Scene-declared `zoom_range` and view-level `zoom_range_override`).
302    /// `None` on either side means "no constraint from that side";
303    /// when both are `None` this is the identity. Tightening-only —
304    /// neither side can loosen what the other imposes.
305    pub(super) fn gate_zoom_target(&self, zoom: f32) -> f32 {
306        clamp_zoom(zoom, self.effective_zoom_range().as_ref())
307    }
308
309    /// Effective zoom range = intersect(Scene declared, view override).
310    fn effective_zoom_range(&self) -> Option<std::ops::RangeInclusive<f32>> {
311        intersect_zoom_range(
312            self.scene().current_zoom_range().as_ref(),
313            self.zoom_range_override.get().as_ref(),
314        )
315    }
316
317    /// Animate rotation to `target` over `duration` (radians).
318    pub fn rotate_to(&self, target_radians: f32, duration: Duration) {
319        self.rotation
320            .animate_to(target_radians, duration, Easing::EaseOut);
321    }
322
323    /// Snap rotation to `target` without animation.
324    pub fn set_rotation(&self, target_radians: f32) {
325        self.rotation.set(target_radians);
326    }
327
328    /// Snapshot the current pan / zoom / rotation as a
329    /// [`SceneViewState`](crate::SceneViewState). Designed for
330    /// persistence: store the snapshot in your settings layer on
331    /// app exit, restore it via [`restore_state`](Self::restore_state)
332    /// on next launch.
333    ///
334    /// The snapshot reflects the *current* signal values — if a
335    /// pan/zoom animation is in flight, the captured values are
336    /// the in-flight tween position, not the eventual target.
337    /// Apps that want to capture the target should query
338    /// [`pan_x_animation_target`](Self::pan_x_animation_target) /
339    /// friends manually.
340    pub fn state(&self) -> crate::SceneViewState {
341        crate::SceneViewState {
342            pan_x: self.pan_x.get(),
343            pan_y: self.pan_y.get(),
344            zoom: self.zoom.get(),
345            rotation: self.rotation.get(),
346        }
347    }
348
349    /// Restore a previously captured [`SceneViewState`](crate::SceneViewState).
350    /// Snaps each signal to the saved value (no animation —
351    /// pan/zoom/rotation jump to the persisted state immediately).
352    /// Zoom is clamped to `[min_zoom, max_zoom]`.
353    pub fn restore_state(&self, state: crate::SceneViewState) {
354        self.pan_x.set(state.pan_x);
355        self.pan_y.set(state.pan_y);
356        self.zoom.set(self.gate_zoom_target(state.zoom));
357        self.rotation.set(state.rotation);
358    }
359
360    /// Latest viewport size observed during layout. Useful for
361    /// imperative `fit_*` calls.
362    pub fn viewport_size(&self) -> Size {
363        self.last_viewport.get()
364    }
365
366    /// Compute the bounding rectangle (in scene coords) that encloses
367    /// every item in the scene. Returns `None` for an empty scene.
368    pub fn scene_content_bounds(&self) -> Option<Rect> {
369        let ids: Vec<ItemId> = self.scene().ids();
370        union_rects(ids.iter().filter_map(|id| self.scene().scene_rect(*id)))
371    }
372
373    /// Animate pan + zoom so the scene's content bounding box fits
374    /// the current viewport with a small margin. No-op for an empty
375    /// scene. Resets rotation to 0.
376    pub fn fit_to_content(&self) {
377        if let Some(content) = self.scene_content_bounds() {
378            self.fit_to_rect(content);
379        }
380    }
381
382    /// Animate pan + zoom so the union of the given items' bounds
383    /// fits the current viewport. Ids not currently in the scene
384    /// are skipped silently. No-op if `ids` is empty or all ids are
385    /// stale. Resets rotation to 0.
386    ///
387    /// Use this for "zoom to selection" / "frame this subset" UX.
388    pub fn fit_to_items(&self, ids: &[ItemId]) {
389        let union = union_rects(ids.iter().filter_map(|id| self.scene().scene_rect(*id)));
390        if let Some(rect) = union {
391            self.fit_to_rect(rect);
392        }
393    }
394
395    /// Animate pan + zoom so the bounds of the currently selected
396    /// items fit the viewport. No-op when nothing is selected.
397    /// Convenience for the common "F to focus selection" hotkey.
398    pub fn fit_to_selection(&self) {
399        let ids = self.selection.selected();
400        if !ids.is_empty() {
401            self.fit_to_items(&ids);
402        }
403    }
404
405    /// Internal: shared math for `fit_to_content` /
406    /// `fit_to_items` / `fit_to_selection`. Animates pan + zoom so
407    /// `rect` fits the current viewport with a margin, and resets
408    /// rotation to 0.
409    fn fit_to_rect(&self, rect: Rect) {
410        let viewport = self.last_viewport.get();
411        let margin = 24.0;
412        let avail_w = (viewport.width - margin * 2.0).max(1.0);
413        let avail_h = (viewport.height - margin * 2.0).max(1.0);
414        let raw_scale = (avail_w / rect.width.max(1.0)).min(avail_h / rect.height.max(1.0));
415        let scale = self.gate_zoom_target(raw_scale);
416        let center = rect.center();
417        let pan = Vec2::new(
418            viewport.width * 0.5 - scale * center.x,
419            viewport.height * 0.5 - scale * center.y,
420        );
421        self.zoom_to(scale, self.zoom_anim_duration);
422        self.rotate_to(0.0, self.zoom_anim_duration);
423        self.pan_to(pan, self.zoom_anim_duration);
424    }
425}