teksilo_scene/view.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SceneView`] — the viewport widget that hosts a [`Scene`] and
5//! places its items at scene coordinates.
6//!
7//! `SceneView` is the bridge between the model layer ([`Scene`] /
8//! [`SceneModel`]) and the render/event pipeline. It
9//! manages a pan/zoom/rotation camera, materialises heavyweight widgets
10//! for delegated items, dispatches pointer events to lightweight item
11//! handlers, and feeds synthetic AT nodes to AccessKit for every visible
12//! lightweight item. Multiple `SceneView`s can share one `SceneModel` and
13//! reconcile independently on every mutation.
14//!
15//! ## Composition
16//!
17//! - **Placement.** `place_children` plants each materialised
18//! heavyweight widget at its scene-space rect (composed from the
19//! item's `local_pos`, `transform`, and parent chain).
20//! - **Paint bands.** Three passes: `paint` draws the `Under` lightweight
21//! items (backdrop), the arena child-walk draws the heavyweight widgets,
22//! then `post_paint` draws the `Over` lightweight items + marquee /
23//! foreground / debug overlays. `z` orders within each tier; the
24//! Under/Over band ([`Scene::set_layer`](crate::Scene::set_layer))
25//! chooses the side. See `docs/teksilo-scene.md` §"Z-order and paint bands".
26//! - **View transform.** Pan / zoom / rotation are four animated
27//! `Signal<f32>`s on `SceneView`, composed into a derived
28//! `Signal<Transform2D>` bound via `BuildContext::set_content_transform`
29//! on the view itself. The render walker pushes that scope around
30//! the entire subtree, so every materialised widget is visually
31//! transformed; transform-aware hit-test routes pointer events
32//! through the same scope.
33//! - **Spatial index.** `place_children` and the paint walk consult
34//! `Scene::items_in_rect(visible_region)` to skip off-screen items.
35//! - **Idle gating.** Pan / zoom that's reached its terminal tick
36//! stops scheduling frames via the engine's per-node `paint_epoch`.
37//!
38//! ## Input wiring
39//!
40//! - **`on_scroll`** — trackpad two-finger pan (`ScrollDelta::Pixels`)
41//! and mouse wheel (`ScrollDelta::Lines`) animate the pan signals
42//! via `Easing::EaseOut`. Trackpad momentum events from winit
43//! arrive as further `Pixels` deltas; the existing animation
44//! pipeline turns this into smooth inertial fling without a custom
45//! recognizer.
46//! - **`on_pinch`** — OS trackpad pinch (`PinchPhase::Changed`) feeds
47//! `scale` into the zoom signal and `rotation` into the rotation
48//! signal, anchored around the gesture center so the scene point
49//! under the user's fingers stays put.
50//! - **Reduced-motion** — at build time, captures
51//! [`BuildContext::prefers_reduced_motion`](teksilo_core::build_context::BuildContext::prefers_reduced_motion).
52//! When set, scroll handlers `set` the signals directly instead of
53//! `animate_to`-ing them; pinch is already instantaneous.
54//! - **Drag-to-move** for items carrying `IS_DRAGGABLE`; **marquee**
55//! selection on the empty viewport surface (or under
56//! [`DragMode::ScrollHandDrag`](crate::DragMode), pan-on-drag).
57//!
58//! ## Example
59//!
60//! ```rust
61//! # use teksilo_scene::{Scene, SceneModel, SceneView, SceneSelectionMode, RectItem};
62//! # use teksilo_canvas::{Point, Rect};
63//! # use teksilo_tokens::Color;
64//! // Build a shared model and add a lightweight rect item.
65//! let model = SceneModel::new();
66//! let local_bounds = Rect::new(0.0, 0.0, 120.0, 80.0);
67//! let item_id = model.add_item(
68//! RectItem::new(local_bounds).fill(Color::from_rgb(0.2, 0.5, 0.8)),
69//! Point::new(50.0, 50.0), // local_pos in scene coords
70//! );
71//!
72//! // Create viewports backed by that model; each has its own camera.
73//! let _view_a = SceneView::with_model(model.clone())
74//! .selection_mode(SceneSelectionMode::Single)
75//! .default_size(800.0, 600.0)
76//! .initial_zoom(1.5);
77//!
78//! let _view_b = SceneView::with_model(model.clone())
79//! .interactive(false); // axis-chrome / overview pane
80//!
81//! // Both views see the item; the model remembers its local_pos.
82//! assert!(model.local_pos(item_id).is_some());
83//! ```
84
85use std::cell::{Cell, RefCell};
86use std::collections::{HashMap, HashSet};
87use std::rc::Rc;
88use std::time::Duration;
89
90use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D, Vec2};
91use teksilo_core::binding::BindingLevel;
92use teksilo_core::build_context::BuildContext;
93use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
94use teksilo_core::gesture::PinchPhase;
95use teksilo_core::overscroll::OverscrollBehavior;
96use teksilo_core::signal::Signal;
97use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
98use teksilo_core::widget_builder::HandlerSet;
99use teksilo_core::widget_id::WidgetId;
100use teksilo_tokens::Easing;
101
102use crate::item::ItemId;
103use crate::magnet::{MagnetId, MagnetSnap, MagnetismConfig};
104use crate::scene::Scene;
105use crate::scene_model::SceneModel;
106use crate::transform::{anchor_pan_for_pinch, compose_view};
107use teksilo_i18n::LocalizedString;
108
109/// Logical pixels of pan applied per `ScrollDelta::Lines` notch.
110/// Mirrors the convention used by `ScrollArea` (`line_height` ≈ 16 in
111/// teksilo-widgets).
112const DEFAULT_LINE_HEIGHT: f32 = 16.0;
113const DEFAULT_PAN_DURATION: Duration = Duration::from_millis(120);
114const DEFAULT_ZOOM_DURATION: Duration = Duration::from_millis(180);
115const DEFAULT_MIN_ZOOM: f32 = 0.1;
116const DEFAULT_MAX_ZOOM: f32 = 10.0;
117
118/// Maximum movement (scene-coord pixels) between PointerDown and
119/// PointerUp for the gesture to count as a tap rather than a drag.
120const TAP_MOVEMENT_THRESHOLD: f32 = 4.0;
121/// Take the tightening intersection of two optional zoom ranges:
122/// `(max(lo), min(hi))`. `None` on either side leaves the other
123/// untouched; `None` on both returns `None`. Used to compose
124/// Scene-level + view-level constraints — neither side can loosen.
125fn intersect_zoom_range(
126 a: Option<&std::ops::RangeInclusive<f32>>,
127 b: Option<&std::ops::RangeInclusive<f32>>,
128) -> Option<std::ops::RangeInclusive<f32>> {
129 match (a, b) {
130 (None, None) => None,
131 (Some(r), None) | (None, Some(r)) => Some(r.clone()),
132 (Some(a), Some(b)) => {
133 let lo = a.start().max(*b.start());
134 let hi = a.end().min(*b.end());
135 // Guard against degenerate intersect: if the ranges
136 // don't overlap (lo > hi), collapse to the tighter
137 // side's lo so callers see a single allowed value
138 // rather than NaN-clamping.
139 Some(lo..=hi.max(lo))
140 }
141 }
142}
143
144/// Clamp a zoom factor through an optional range. `None` is the
145/// identity — no clamp applied.
146fn clamp_zoom(z: f32, range: Option<&std::ops::RangeInclusive<f32>>) -> f32 {
147 match range {
148 None => z,
149 Some(r) => z.clamp(*r.start(), *r.end()),
150 }
151}
152
153/// Take the tightening intersection of two optional pan-bounds
154/// rects. `None` on either side leaves the other untouched; `None`
155/// on both returns `None`. If both are `Some` and the rect
156/// intersection is empty (no overlap), falls back to the first
157/// (Scene-declared) bounds — the more authoritative side.
158fn intersect_pan_bounds(scene: Option<Rect>, view: Option<Rect>) -> Option<Rect> {
159 match (scene, view) {
160 (None, None) => None,
161 (Some(r), None) | (None, Some(r)) => Some(r),
162 (Some(a), Some(b)) => {
163 let x = a.x.max(b.x);
164 let y = a.y.max(b.y);
165 let right = a.right().min(b.right());
166 let bottom = a.bottom().min(b.bottom());
167 if right > x && bottom > y {
168 Some(Rect::new(x, y, right - x, bottom - y))
169 } else {
170 Some(a)
171 }
172 }
173 }
174}
175
176/// Clamp a pan vector against `bounds` so the visible scene region
177/// (derived from `viewport` and `zoom`) stays inside the bounds rect.
178/// When the rect is smaller than the visible viewport on an axis,
179/// that axis is centered on the bounds rather than clamped.
180///
181/// `bounds` is in scene coords; `viewport` is the SceneView's
182/// resolved size in screen pixels; `zoom` is the current zoom
183/// factor. Returns `pan` unchanged when `bounds` is `None`.
184fn clamp_pan_to_bounds(pan: Vec2, bounds: Option<&Rect>, viewport: Size, zoom: f32) -> Vec2 {
185 let Some(b) = bounds else { return pan };
186 if zoom <= 0.0 || viewport.width <= 0.0 || viewport.height <= 0.0 {
187 return pan;
188 }
189 // visible_scene_x = [-pan.x / zoom, (viewport_w - pan.x) / zoom]
190 // For visible to lie inside [b.x, b.right]:
191 // pan.x in [viewport_w - b.right * zoom, -b.x * zoom]
192 let clamp_axis = |pan_c: f32, b_lo: f32, b_hi: f32, vp: f32| {
193 let lo = vp - b_hi * zoom;
194 let hi = -b_lo * zoom;
195 if hi >= lo {
196 pan_c.clamp(lo, hi)
197 } else {
198 // Bounds smaller than viewport on this axis — center.
199 // visible_center = b_lo + (b_hi - b_lo)/2
200 // = (b_lo + b_hi) / 2
201 // = (vp/2 - pan_c) / zoom
202 // → pan_c = vp/2 - (b_lo + b_hi)/2 * zoom
203 vp / 2.0 - ((b_lo + b_hi) / 2.0) * zoom
204 }
205 };
206 Vec2::new(
207 clamp_axis(pan.x, b.x, b.right(), viewport.width),
208 clamp_axis(pan.y, b.y, b.bottom(), viewport.height),
209 )
210}
211
212/// The single chokepoint for the pan-bounds clamp: take the tightening
213/// intersection of the Scene-declared and view-override bounds, then
214/// clamp `candidate` against it for the given `viewport` and `zoom`.
215///
216/// Every pan path (the camera API plus all five gesture handlers) ends
217/// in this exact `intersect → clamp` pair. Funnelling them through one
218/// function keeps a new pan path from silently skipping the intersection
219/// or the clamp. Each site still computes its own `candidate` (the parts
220/// that legitimately differ — `z_new` vs committed zoom for the
221/// zoom-coupled sites, `animation_target` vs live pan for the tween-
222/// chaining sites — stay at the call site).
223fn clamp_pan(
224 candidate: Vec2,
225 scene_bounds: Option<Rect>,
226 view_bounds: Option<Rect>,
227 viewport: Size,
228 zoom: f32,
229) -> Vec2 {
230 let effective = intersect_pan_bounds(scene_bounds, view_bounds);
231 clamp_pan_to_bounds(candidate, effective.as_ref(), viewport, zoom)
232}
233
234/// Apply a [`PanAxes`](crate::scene::PanAxes) policy to a candidate pan:
235/// the permitted axis takes `candidate`, the restricted axis is held at
236/// `hold` (the gesture's reference pan). `Both` passes through; `None`
237/// holds both.
238///
239/// Shared by the pinch and hand-drag sites, which hold the orthogonal
240/// axis at the pan captured when the gesture began, and by the camera's
241/// `gate_pan_target`, which holds it at the current pan. The wheel and
242/// keyboard sites instead zero their input *deltas* (so an excluded axis
243/// passes through to ancestor scrollables / arrow-key guards) and do not
244/// call this.
245fn apply_pan_axes(candidate: Vec2, hold: Vec2, axes: crate::scene::PanAxes) -> Vec2 {
246 use crate::scene::PanAxes;
247 match axes {
248 PanAxes::Both => candidate,
249 PanAxes::None => hold,
250 PanAxes::Horizontal => Vec2::new(candidate.x, hold.y),
251 PanAxes::Vertical => Vec2::new(hold.x, candidate.y),
252 }
253}
254
255/// In-flight marquee box-select state. Tracked in scene
256/// coordinates so pan/zoom mid-drag (e.g. the user holds shift
257/// and scrolls while dragging) doesn't break the rectangle's
258/// alignment with scene contents.
259#[derive(Debug, Clone, Copy)]
260struct MarqueeState {
261 origin: Point,
262 current: Point,
263 /// Whether the marquee is additive (Ctrl/Shift held at start).
264 /// On commit, additive = `extend`; non-additive = `replace`.
265 additive: bool,
266}
267
268impl MarqueeState {
269 fn rect(self) -> Rect {
270 let x = self.origin.x.min(self.current.x);
271 let y = self.origin.y.min(self.current.y);
272 let w = (self.origin.x - self.current.x).abs();
273 let h = (self.origin.y - self.current.y).abs();
274 Rect::new(x, y, w, h)
275 }
276}
277
278/// A drag-to-move in flight: which lightweight item is being
279/// translated, in scene coords. The committed delta on `Ended`
280/// is `current_scene - anchor_scene`; that delta is applied to
281/// the target item *and* every declared descendant via
282/// `Scene::collect_descendants`.
283#[derive(Debug, Clone, Copy)]
284struct DragTarget {
285 item_id: ItemId,
286 /// Scene-coord position where the drag started.
287 anchor_scene: Point,
288 /// Current scene-coord position (updated on each Moved /
289 /// Ended). Allows paint to render the in-flight offset for
290 /// live visual feedback.
291 current_scene: Point,
292}
293
294/// Snapshot of one item's hit-test geometry + handler closures used
295/// by the SceneView's `on_pointer_event` dispatch path. Refreshed
296/// per layout pass alongside `lightweight_bounds_snapshot`.
297#[derive(Clone)]
298struct HandlerSnapshotEntry {
299 id: crate::item::ItemId,
300 /// Scene-coord AABB used for broad-phase hit-test (normal items).
301 scene_rect: Rect,
302 /// Local→scene transform — used to inverse-project the
303 /// scene-coord pointer into local coords for shape_contains
304 /// narrow-phase. Stored so the dispatch path doesn't have to
305 /// re-walk the parent chain (which would need `&Scene`).
306 scene_transform: teksilo_canvas::Transform2D,
307 /// Item-local hit-test predicate, cloned from the trait via a
308 /// small wrapper. Returns `true` when a local point is inside
309 /// the item's exact shape; the second argument is the live view
310 /// scale (zoom) so cosmetic-stroke hit bands convert to scene
311 /// coordinates.
312 shape_contains: Rc<dyn Fn(Point, f32) -> bool>,
313 /// z-order (used to pick topmost on overlap).
314 z: f32,
315 /// Item-level handler closures, cloned at snapshot time. `None`
316 /// when the item has no handler set installed.
317 handlers: Option<Box<crate::item_handlers::SceneItemHandlerSet>>,
318 /// `true` when the item carries `ItemFlags::IGNORES_TRANSFORMATIONS`.
319 /// Dispatch routes hit-test through screen space: the visible
320 /// area is `local_bounds` rooted at the screen-projected
321 /// `scene_anchor`, and pan/zoom of the view don't change that
322 /// area. `scene_rect` is meaningless for these items because
323 /// they don't scale with zoom.
324 ignores_xform: bool,
325 /// For IGNORES items: the item's origin (local `(0,0)`) mapped
326 /// to scene coords through the parent chain. The current view
327 /// transform projects this to the screen-space anchor at
328 /// dispatch time. For normal items, unused.
329 scene_anchor: Point,
330 /// For IGNORES items: the item's `local_bounds`. Combined with
331 /// the screen anchor at dispatch time to form the screen-space
332 /// AABB. For normal items, unused.
333 local_bounds: Rect,
334}
335
336/// Hit-test geometry for one **draggable** lightweight item, snapshotted each
337/// layout pass for the `on_drag` drag-start hit-test and the grab-cursor hover
338/// check. Carries the narrow-phase `shape_contains` predicate + transform (the
339/// same data `HandlerSnapshotEntry` holds for tap/hover) so a press targets the
340/// item only when it lands on the item's **actual shape**, not merely its AABB
341/// — important for thin draggable items (e.g. a connector path) whose bounding
342/// box is much larger than the drawn stroke. z-sorted descending so the first
343/// shape match is the topmost.
344#[derive(Clone)]
345struct DraggableSnapshotEntry {
346 id: ItemId,
347 scene_rect: Rect,
348 scene_transform: teksilo_canvas::Transform2D,
349 shape_contains: Rc<dyn Fn(Point, f32) -> bool>,
350 ignores_xform: bool,
351 scene_anchor: Point,
352 local_bounds: Rect,
353 /// z-order (used to pick topmost on overlap).
354 z: f32,
355}
356
357impl std::fmt::Debug for DraggableSnapshotEntry {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("DraggableSnapshotEntry")
360 .field("id", &self.id)
361 .field("scene_rect", &self.scene_rect)
362 .finish_non_exhaustive()
363 }
364}
365
366/// The topmost draggable item whose **shape** contains the pointer (narrow
367/// phase), or `None`. Mirrors the `hit_handler_item` logic used for tap/hover
368/// dispatch: AABB broad-phase, then inverse-project to local and consult
369/// `shape_contains`, with a screen-space branch for `IGNORES_TRANSFORMATIONS`
370/// items. `snap` must be z-sorted descending (topmost first).
371fn hit_draggable_item(
372 snap: &[DraggableSnapshotEntry],
373 screen_pt: Point,
374 scene_pt: Point,
375 view_xform: teksilo_canvas::Transform2D,
376) -> Option<ItemId> {
377 let view_scale = view_xform.m[0].hypot(view_xform.m[1]);
378 for entry in snap.iter() {
379 if entry.ignores_xform {
380 let screen_anchor = view_xform.apply_point(entry.scene_anchor);
381 let screen_rect = Rect::new(
382 screen_anchor.x + entry.local_bounds.x,
383 screen_anchor.y + entry.local_bounds.y,
384 entry.local_bounds.width,
385 entry.local_bounds.height,
386 );
387 if !screen_rect.contains(screen_pt) {
388 continue;
389 }
390 let local_pt = Point::new(screen_pt.x - screen_anchor.x, screen_pt.y - screen_anchor.y);
391 // Screen-anchored items ignore the view transform → unit scale.
392 if (entry.shape_contains)(local_pt, 1.0) {
393 return Some(entry.id);
394 }
395 continue;
396 }
397 if !entry.scene_rect.contains(scene_pt) {
398 continue;
399 }
400 let local_pt = entry
401 .scene_transform
402 .inverse()
403 .map(|inv| inv.apply_point(scene_pt))
404 .unwrap_or(Point::ZERO);
405 if (entry.shape_contains)(local_pt, view_scale) {
406 return Some(entry.id);
407 }
408 }
409 None
410}
411
412/// Visual debug overlays painted on top of normal scene rendering.
413///
414/// Every flag defaults to `false`. Use this to verify that culling /
415/// hit-test / spatial-index / dragging are doing what you expect
416/// while developing a scene-based feature; turn off before shipping.
417///
418/// Each flag adds a thin overlay paint with a distinct color so
419/// multiple flags can be combined without visual confusion:
420///
421/// - [`item_bounds`](Self::item_bounds): green outline around every
422/// visible scene item's `bounds_in_scene`.
423/// - [`content_bounds`](Self::content_bounds): blue outline around
424/// the scene's overall content extent (the union of all item
425/// bounds).
426/// - [`viewport`](Self::viewport): red outline around the visible
427/// scene region (the cull rect — the inverse-projected viewport).
428/// - [`selection_bounds`](Self::selection_bounds): orange outline
429/// around every currently-selected item.
430#[derive(Debug, Clone, Copy, Default)]
431pub struct DebugOverlay {
432 pub item_bounds: bool,
433 pub content_bounds: bool,
434 pub viewport: bool,
435 pub selection_bounds: bool,
436}
437
438impl DebugOverlay {
439 /// All overlays enabled. Useful to catch any anomaly visually.
440 pub const ALL: DebugOverlay = DebugOverlay {
441 item_bounds: true,
442 content_bounds: true,
443 viewport: true,
444 selection_bounds: true,
445 };
446
447 /// Whether at least one debug overlay is enabled.
448 pub fn is_active(&self) -> bool {
449 self.item_bounds || self.content_bounds || self.viewport || self.selection_bounds
450 }
451}
452
453/// Direction passed to a [`SceneView::focus_order`] callback when the
454/// app wants to override the default Tab cycle.
455///
456/// `Forward` corresponds to Tab; `Backward` to Shift+Tab. The default
457/// SceneView focus traversal is scene insertion order — apps that
458/// need data-flow order (graph editor), story-order (corkboard with
459/// Acts), chronological order (timeline), etc. install a callback
460/// that receives the current focus and returns the next id.
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum FocusDirection {
463 /// Advance to the next item — corresponds to the Tab key.
464 Forward,
465 /// Retreat to the previous item — corresponds to Shift+Tab.
466 Backward,
467}
468
469/// A pannable/zoomable viewport that renders a [`Scene`]'s items at scene
470/// coordinates and routes user input (scroll, pinch, drag, keyboard) back into
471/// the camera signals.
472///
473/// Construct with [`SceneView::new`] (single-view sugar: wraps a [`Scene`] in a
474/// fresh [`SceneModel`]) or [`SceneView::with_model`] (multi-view: several
475/// viewports share one [`SceneModel`] and each reconcile independently on every
476/// mutation). Install a heavyweight builder for delegated items via
477/// [`delegate_typed`](Self::delegate_typed). Add to a [`WidgetTree`](teksilo_core::widget_tree::WidgetTree)
478/// like any other widget; gestures and camera animations are wired automatically
479/// during [`build`](teksilo_core::widget::Widget::build).
480///
481/// See the [module-level documentation](crate) for the full composition model
482/// and `docs/teksilo-scene.md` for an end-to-end guide.
483pub struct SceneView {
484 /// Shared, cloneable handle to the scene this view renders. Multiple
485 /// `SceneView`s can hold clones of one [`SceneModel`] and reconcile
486 /// independently on every mutation.
487 model: SceneModel,
488 /// Per-view heavyweight builder for `Delegated` items. Each view calls
489 /// its own delegate with an item's type-erased payload to build a fresh
490 /// `Widget` instance for **this** view's arena. Returns `None` to skip
491 /// an item (e.g. a downcast miss). `None` (the field) = no delegate
492 /// installed; only single-view `Once` widgets materialise.
493 delegate: Option<Rc<dyn Fn(&dyn std::any::Any, ItemId) -> Option<Box<dyn Widget>>>>,
494 /// Items whose payload changed since the last build (filled by the
495 /// `item_change` observer on `ItemChange::PayloadChanged`). Drained at
496 /// the top of `build`, where each is destroyed and re-materialised via the
497 /// delegate. `Rc<RefCell>` so the observer closure can push without
498 /// borrowing `model`.
499 payload_dirty: Rc<RefCell<HashSet<ItemId>>>,
500 /// Materialisation map populated during `build`. Stable across
501 /// rebuilds — subsequent `build` calls just return the cached
502 /// widget ids.
503 materialized: HashMap<ItemId, WidgetId>,
504 /// Reverse lookup populated alongside `materialized` so the
505 /// per-frame `place_children` cull resolves
506 /// `WidgetId → ItemId` in `O(1)`. Without it, scaling the demo
507 /// to 5,000 cards would burn a full frame's budget on the
508 /// per-child entry scan.
509 widget_to_item: HashMap<WidgetId, ItemId>,
510 /// Live mirror of `bounds.origin` (the SceneView's screen-space
511 /// position as decided by its parent layout). Updated in
512 /// `place_children` and folded into the view-transform composition
513 /// so a SceneView positioned at a non-zero parent offset still
514 /// places its children correctly under pan / zoom / rotation.
515 /// Without this, zoom would multiply `bounds.origin` and the
516 /// content would visually drift away from the viewport.
517 bounds_origin_signal: Signal<Vec2>,
518 /// Fallback size when the parent's `SizeProposal` is unspecified
519 /// on either axis.
520 default_size: Size,
521 /// When `true`, [`SceneView::layout_response`] returns the
522 /// scene's `scene_rect_extent` as the view's wanted size — the
523 /// view sizes itself to its scene. User pan / zoom / drag-to-
524 /// move are still gated by [`Scene::pan_axes`] and
525 /// [`Scene::is_zoomable`]; the default policy is "no pan, no
526 /// zoom" because the entire scene is already on-screen.
527 adopt_scene_size: bool,
528 /// Drag-on-canvas behavior. Default `RubberBand` (item drag →
529 /// move; empty area → marquee). `ScrollHandDrag` makes the
530 /// canvas pan unconditionally on left-mouse drag; `NoDrag`
531 /// disables the on-drag handler entirely.
532 /// Drag mode (rubber-band marquee, scroll-hand pan, or no-drag).
533 /// Reactive: gesture handlers read this per event, so mutating
534 /// the signal at runtime (typically from a toolbar) flips
535 /// behaviour on the next pointer event without rebuilding
536 /// the view. `.drag_mode(mode)` writes to it directly;
537 /// `.drag_mode(sig)` replaces the inner signal with an
538 /// app-owned one so toolbars can share state with the view.
539 drag_mode: Signal<crate::item_handlers::DragMode>,
540 /// Per-layout snapshot of (id, scene_rect, handlers) for items
541 /// that have a handler set installed. Used by the
542 /// `on_pointer_event` closure to dispatch hover / tap / context
543 /// menu without borrowing `&self.scene`. Refreshed in
544 /// `layout_response`.
545 handler_snapshot: Rc<RefCell<Vec<HandlerSnapshotEntry>>>,
546 /// Currently-hovered item id, used to dispatch `on_hover(false)`
547 /// when the pointer leaves it.
548 hovered_item: Rc<Cell<Option<crate::item::ItemId>>>,
549 /// Last press recorded for tap detection: (scene_pt, item_id).
550 /// Cleared on PointerUp / PointerLeave.
551 pending_tap: Rc<
552 Cell<
553 Option<(
554 Point,
555 crate::item::ItemId,
556 teksilo_core::event::PointerButton,
557 )>,
558 >,
559 >,
560 /// Latest viewport size observed during layout. Cached so
561 /// imperative methods like [`SceneView::fit_to_content`] can
562 /// reason about the visible rectangle without re-running layout.
563 /// `Rc<Cell>` so event-handler closures (e.g. Ctrl+wheel zoom-
564 /// about-viewport-center) can read it without touching `&mut self`.
565 /// Last viewport size resolved by `layout_response`. Stored as a
566 /// `Signal` (not `Cell`) so derived signals like
567 /// [`viewport_in_scene_signal`](Self::viewport_in_scene_signal)
568 /// can react to viewport changes. Writes are gated by an
569 /// equality check at the call site to avoid notifying on
570 /// unchanged values.
571 last_viewport: Signal<Size>,
572
573 // --- View transform state ---------------------------------
574 pan_x: Signal<f32>,
575 pan_y: Signal<f32>,
576 zoom: Signal<f32>,
577 rotation: Signal<f32>,
578
579 // --- View configuration ----------------------------------------
580 /// View-level *tightening* override on the underlying
581 /// [`Scene`]'s zoom range. The effective clamp applied at
582 /// gesture / set_zoom / pan_to time is the intersection of
583 /// `Scene::current_zoom_range()` and this override (see
584 /// `effective_zoom_range`). `None` means the view does not
585 /// constrain zoom; the default is `Some(0.1..=10.0)` so
586 /// existing callers see the historical clamp behaviour.
587 zoom_range_override: Signal<Option<std::ops::RangeInclusive<f32>>>,
588 /// View-level *tightening* override on the underlying
589 /// [`Scene`]'s pan bounds. The effective clamp is the rect
590 /// intersection of `Scene::current_pan_bounds()` and this
591 /// override. `None` (the default) leaves pan unconstrained
592 /// from the view side.
593 pan_bounds_override: Signal<Option<Rect>>,
594 pan_anim_duration: Duration,
595 zoom_anim_duration: Duration,
596 line_height: f32,
597 /// Whether a wheel that the scene can't absorb (already clamped at its
598 /// `pan_bounds`) chains to an ancestor scrollable, or is contained.
599 overscroll_behavior: OverscrollBehavior,
600
601 // --- A11y configuration — visual-default path ----------------------------------
602 a11y_off_screen_mode: crate::a11y::A11yOffScreenMode,
603
604 // --- A11y configuration — logical structural API ----------------------------------
605 /// Cooperative (default) vs StrictlyParallel.
606 a11y_mode: crate::a11y::A11yMode,
607 /// SceneView's own arena `WidgetId`, captured during the first
608 /// `build()`. Needed by `a11y_redirect_descendant` to compute
609 /// the synthetic `NodeId` of a declared logical parent group
610 /// (the hash key is `(self_id, group_id, SyntheticKind::SceneGroup)`).
611 /// `Cell` because the trait method is `&self`.
612 self_widget_id: Cell<Option<WidgetId>>,
613
614 // --- Interactivity ------------------------------------------------
615 /// When `false`, `build()` skips registering scroll / pinch /
616 /// keyboard handlers and does not mark the SceneView focusable.
617 /// Programmatic `pan_to` / `zoom_to` still work — this only
618 /// gates user-driven navigation. Used by chart-style nested
619 /// scenes where the outer container is purely decorative
620 /// (axis chrome around an inner data SceneView).
621 interactive: bool,
622
623 // --- Selection -----------------------------------------
624 /// Reactive selection state. Defaults to `SceneSelectionMode::None`
625 /// (no selection wired). Apps opt in via
626 /// [`selection_mode`](Self::selection_mode); marquee + click-to-
627 /// select then activate.
628 selection: crate::selection::SceneSelection,
629 /// In-flight marquee state: scene-coord origin + current. While
630 /// `Some`, `paint` overlays a semi-transparent rect.
631 /// `Rc<Cell>` so the on_drag closure (which only borrows
632 /// `&self` shape via the closure's capture) can mutate it.
633 marquee: Rc<Cell<Option<MarqueeState>>>,
634 /// Pending marquee commit: set by the on_drag closure on
635 /// `DragPhase::Ended`, consumed at the start of the next
636 /// `place_children` (which has direct `&self.scene` access
637 /// via `self`). This indirection avoids forcing `Scene` into
638 /// an `Rc<RefCell>`.
639 pending_marquee_commit: Rc<Cell<Option<(Rect, bool)>>>,
640 /// In-flight drag-to-move state: which item is being dragged
641 /// and the scene-coord anchor where the drag started. The
642 /// total scene-coord delta is computed at `Ended` from
643 /// `current - anchor` and posted to `pending_item_move`.
644 /// `Rc<Cell>` so the on_drag closure can mutate via `&self`.
645 drag_target: Rc<Cell<Option<DragTarget>>>,
646 /// Pending drag-to-move commit: `(target_id, delta)` set by
647 /// the on_drag `Ended` branch, drained in `build`. The drain
648 /// code translates the target item AND every descendant
649 /// (declared via `Scene::set_item_parent`) by the same delta
650 /// — so a labelled rectangle (Rect parent + TextItem child)
651 /// moves as one unit, QGraphicsScene-style.
652 pending_item_move: Rc<Cell<Option<(ItemId, Vec2)>>>,
653 /// Snapshot of **draggable** lightweight scene items + their narrow-phase
654 /// hit geometry, used by the on_drag drag-start hit-test and the grab-cursor
655 /// hover check. Refreshed in `place_children` each layout pass — the
656 /// snapshot stays consistent within a single drag and refreshes between
657 /// drags via the spatial-index mutation triggering relayout. Avoids forcing
658 /// `Scene` into an `Rc<RefCell>`.
659 lightweight_bounds_snapshot: Rc<RefCell<Vec<DraggableSnapshotEntry>>>,
660 /// Bumped by the on_drag closure on `Ended` after posting a
661 /// `pending_item_move`. SceneView binds to this at
662 /// `BindingLevel::Rebuild` in `build`, so the next build
663 /// cycle drains the pending move and calls
664 /// `Scene::set_local_pos` (which requires `&mut self.scene`,
665 /// only available inside `build`). Without this signal, the
666 /// move was queued but never applied — items "snapped back"
667 /// to their original positions on drag release.
668 reconcile_dirty: Signal<u64>,
669
670 /// Bumped by the `item_change_signal` observer on an
671 /// [`ItemChange::AppearanceChanged`](crate::ItemChange::AppearanceChanged).
672 /// Bound at `BindingLevel::RepaintOnly` in `build`, so a lightweight item's
673 /// live colour/style change repaints the view (re-running `paint_band` →
674 /// `item.paint`) **without** a relayout or rebuild — the cheap path for a
675 /// pure appearance mutation.
676 appearance_dirty: Signal<u64>,
677
678 /// Latest pointer position seen on the SceneView (screen-space).
679 /// Updated via an on_pointer_event handler in `build`. Used by
680 /// Ctrl+wheel zoom to zoom-about-pointer instead of zoom-about-
681 /// viewport-center, which is the natural feel users expect (the
682 /// scene point under the cursor stays put).
683 /// `None` until the first pointer event arrives — Ctrl+wheel
684 /// before any pointer event falls back to viewport center.
685 cursor_pos: Rc<Cell<Option<Point>>>,
686
687 /// App-supplied focus-order callback. When set, the public
688 /// [`next_focus`](Self::next_focus) /
689 /// [`previous_focus`](Self::previous_focus) accessors route
690 /// through it instead of falling back to insertion order.
691 /// `Rc<dyn Fn>` so callers can clone the SceneView while
692 /// keeping the closure shared.
693 focus_order_callback:
694 Option<Rc<dyn Fn(&Scene, FocusDirection, Option<ItemId>) -> Option<ItemId>>>,
695
696 /// Whether this SceneView is logically nested inside another
697 /// (chart-style outer chrome + inner data scene, or a preview
698 /// pane inside a parent scene). Default `false` — every
699 /// SceneView reports itself as a top-level `Role::Pane`. When
700 /// `true`, the AT walker reports `Role::Region` instead so
701 /// screen readers don't announce redundant landmarks.
702 a11y_nested: bool,
703 /// Optional label announced as the SceneView's own AT name.
704 /// When set, becomes the logical region name (e.g. "Chart
705 /// data area" for an inner chart SceneView). Default `None`
706 /// — the SceneView has no explicit name.
707 a11y_label: Option<LocalizedString>,
708 /// Coordinate space for `SceneItem` bounds reported to AT.
709 /// Default `Screen` (view-projected). Apps with a logical
710 /// fixed coordinate system (CAD canvases, blueprint editors)
711 /// may want `Scene` so AT users can reason about "where in
712 /// the design" an item sits, independent of the current
713 /// pan/zoom.
714 a11y_bounds_space: crate::a11y::A11yBoundsSpace,
715 /// Debug overlay configuration. Default: all flags `false`
716 /// — no debug paint. When any flag is set, the SceneView
717 /// paints visual diagnostics (item bounding boxes,
718 /// content extent, viewport rect, etc.) on top of normal
719 /// scene rendering. Use to verify culling, hit-test, and
720 /// spatial-index behavior; intended for development only,
721 /// don't ship with this on.
722 debug_overlay: DebugOverlay,
723
724 // --- Cached derived signals ---------------------------------------
725 /// `view_transform` as a derived `Signal<Transform2D>`,
726 /// constructed once in `new()` and reused across rebuilds.
727 /// Exposed via [`view_transform_signal`](Self::view_transform_signal)
728 /// so consumers (e.g. axis labels in a parent SceneView) can
729 /// bind to it reactively without taking a snapshot every paint.
730 view_transform_signal: Signal<Transform2D>,
731
732 // --- Background / foreground paint hooks --------------------------
733 /// App-supplied closure painted **before** the items walk. The
734 /// canvas already has the view-transform scope pushed, so the
735 /// closure paints in scene coords. The `Rect` argument is the
736 /// scene-coord visible region — useful for "every-N-units" tiled
737 /// backgrounds (graph-paper grids, ruled lines, dot grids) so the
738 /// closure only emits geometry the user can actually see.
739 background_paint: Option<Rc<dyn Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect)>>,
740 /// App-supplied closure painted **after** the items walk and the
741 /// marquee, but before the debug overlay. Same coordinate
742 /// conventions as `background_paint`. Used for scene-coord
743 /// chrome that should ride over content (rulers, snap-line
744 /// indicators, drop hints).
745 foreground_paint: Option<Rc<dyn Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect)>>,
746
747 // --- Item-coordinate paint cache ----------------------------------
748 /// Per-item paint cache for items that opted into
749 /// [`CacheMode::ItemCoordinate`](crate::cache::CacheMode::ItemCoordinate).
750 /// Keyed by `ItemId`; the entry stores a [`RenderFrame`](teksilo_canvas::RenderFrame)
751 /// recorded in the item's local coordinates and replayed via
752 /// `Canvas::draw_render_frame` when valid. Invalidated by an
753 /// observer on [`Scene::item_change_signal`](crate::Scene::item_change_signal):
754 /// `LocalBoundsChanged` / `OpacityChanged` / `Removed` for an id
755 /// drop that id's entry. Apps that mutate item-internal state
756 /// outside of `Scene` mutators must call
757 /// [`SceneView::invalidate_item_cache`] to evict.
758 pub(crate) item_cache: Rc<RefCell<crate::cache::ItemCoordinateCache>>,
759 /// RAII guard for the cache-invalidation observer wired in
760 /// `build()`. Held by `Self` so the observer's lifetime tracks
761 /// the SceneView's; dropping it on a fresh `build()` un-installs
762 /// the previous observer before re-installing.
763 _item_cache_observer: RefCell<Option<teksilo_core::signal::ObserverHandle>>,
764 /// RAII guard for the logical-AT-structure observer wired in `build()`.
765 /// Held by `Self` so a re-build un-installs the previous observer before
766 /// re-installing. Drives a reconcile pass on `Scene::a11y_change_signal`
767 /// (group / parent / relation / live / landmark / category mutations),
768 /// which don't flow through `item_change_signal`.
769 _a11y_observer: RefCell<Option<teksilo_core::signal::ObserverHandle>>,
770 /// [`Scene::mutation_version`] as of the end of the build that last
771 /// requested an AccessKit re-walk. `None` until the first build. `build()`
772 /// re-walks AT only when the version has advanced past this since the last
773 /// walk (a structural / geometry / a11y mutation), so a `build()` driven
774 /// purely by per-frame dynamic-bounds churn does not re-walk AT 60×/s.
775 last_at_version: Option<u64>,
776 /// Whether [`Scene::refresh_dynamic_bounds`] reported a change on the
777 /// *previous* build. The `true → false` edge (an animation settling) walks
778 /// the final animated bounds into AT once — the one AT update the
779 /// version-delta gate would otherwise miss while suppressing the churn.
780 dynamic_churning: bool,
781
782 // --- Magnetism -----------------------------------------------------
783 /// Per-view magnetism config (predicate, on_connect, feedback, …).
784 /// `None` = magnetism off for this view: no snap, no feedback, no
785 /// magnet AT nodes. Shared `Rc` so the drag / key closures hold a clone.
786 magnetism: Option<Rc<MagnetismConfig>>,
787 /// In-flight port-drag (grabbed a magnet handle, dragging a wire).
788 /// `RefCell` (not `Cell`) because `PortDragState` carries a non-`Copy`
789 /// `Rc` payload.
790 port_drag: Rc<RefCell<Option<magnetism::PortDragState>>>,
791 /// Active item-drag snap (the dragged item's magnet aligned onto a
792 /// target). Drives feedback and the connection fired on release.
793 item_snap: Rc<RefCell<Option<MagnetSnap>>>,
794 /// Whether the keyboard connect mode is active (entered via the
795 /// config's connect key while the view is focused).
796 magnet_connect_mode: Rc<Cell<bool>>,
797 /// The keyboard-focused magnet in connect mode (virtual focus: the
798 /// SceneView keeps real arena focus and points `active_descendant`
799 /// at this magnet's synthetic AT node).
800 magnet_focus: Rc<Cell<Option<MagnetId>>>,
801 /// The keyboard-activated source magnet awaiting a target.
802 magnet_pending: Rc<Cell<Option<MagnetId>>>,
803}
804
805impl std::fmt::Debug for SceneView {
806 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
807 // Manual impl: `focus_order_callback` is `Rc<dyn Fn>` and
808 // therefore not `Debug`. Render it as a presence flag instead.
809 f.debug_struct("SceneView")
810 .field("model", &self.model)
811 .field("materialized_count", &self.materialized.len())
812 .field("default_size", &self.default_size)
813 .field("interactive", &self.interactive)
814 .field("zoom_range_override", &self.zoom_range_override.get())
815 .field("pan_bounds_override", &self.pan_bounds_override.get())
816 .field("a11y_mode", &self.a11y_mode)
817 .field("a11y_off_screen_mode", &self.a11y_off_screen_mode)
818 .field("selection_mode", &self.selection.mode())
819 .field("focus_order_callback", &self.focus_order_callback.is_some())
820 .field("a11y_nested", &self.a11y_nested)
821 .field("a11y_label", &self.a11y_label)
822 .field("a11y_bounds_space", &self.a11y_bounds_space)
823 .field("debug_overlay", &self.debug_overlay)
824 .finish_non_exhaustive()
825 }
826}
827
828mod a11y_impl;
829mod build_impl;
830mod builder_impl;
831mod camera_impl;
832mod gestures_impl;
833mod layout_impl;
834mod magnetism;
835mod paint_impl;
836mod widget_trait;
837
838/// Union an iterator of axis-aligned rectangles into a single
839/// bounding rectangle. Returns `None` if the iterator is empty.
840fn union_rects(mut rects: impl Iterator<Item = Rect>) -> Option<Rect> {
841 let first = rects.next()?;
842 let mut min_x = first.x;
843 let mut min_y = first.y;
844 let mut max_x = first.right();
845 let mut max_y = first.bottom();
846 for r in rects {
847 if r.x < min_x {
848 min_x = r.x;
849 }
850 if r.y < min_y {
851 min_y = r.y;
852 }
853 if r.right() > max_x {
854 max_x = r.right();
855 }
856 if r.bottom() > max_y {
857 max_y = r.bottom();
858 }
859 }
860 Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
861}
862
863#[cfg(test)]
864mod tests;