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}