Skip to main content

teksilo_scene/view/
builder_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Builder and configuration methods for [`SceneView`].
5//!
6//! Covers construction (`new` / `with_model`), delegate wiring, selection,
7//! camera seeding (`initial_pan` / `initial_zoom` / `view_state`),
8//! zoom/pan-bound overrides, drag mode, background/foreground paint hooks,
9//! magnetism, debug overlays, accessibility tuning (`a11y_mode`,
10//! `a11y_off_screen_mode`, `nested_a11y`), focus-order
11//! callbacks, reactive signal accessors, and the `with_scroll_bars` adaptor.
12
13use super::*;
14use teksilo_core::signal::Prop;
15
16impl SceneView {
17    /// Wrap a [`Scene`] in a viewport (single-view sugar). The scene is moved
18    /// into a fresh [`SceneModel`]; for multi-view, build a `SceneModel`
19    /// yourself and use [`with_model`](Self::with_model).
20    pub fn new(scene: Scene) -> Self {
21        Self::with_model(SceneModel::from_scene(scene))
22    }
23
24    /// Attach a viewport to a (possibly shared) [`SceneModel`]. Clone one
25    /// model into several `SceneView::with_model(model.clone())` to render the
26    /// same scene in multiple panes, each with its own camera and delegate.
27    pub fn with_model(model: SceneModel) -> Self {
28        let pan_x = Signal::new_animated(0.0);
29        let pan_y = Signal::new_animated(0.0);
30        let zoom = Signal::new_animated(1.0);
31        let rotation = Signal::new_animated(0.0);
32        let bounds_origin_signal = Signal::new(Vec2::ZERO);
33        // Derived view-transform signal — composed once in `new` so
34        // it's stable across rebuilds. The same instance is used by
35        // `set_content_transform` in `build` and exposed publicly via
36        // [`view_transform_signal`](Self::view_transform_signal).
37        let view_transform_signal =
38            Self::compose_view_transform(&pan_x, &pan_y, &zoom, &rotation, &bounds_origin_signal);
39        Self {
40            model,
41            delegate: None,
42            payload_dirty: Rc::new(RefCell::new(HashSet::new())),
43            materialized: HashMap::new(),
44            widget_to_item: HashMap::new(),
45            at_heavy: Rc::new(RefCell::new(None)),
46            at_children: Rc::new(RefCell::new(None)),
47            retention_margin: DEFAULT_RETENTION_MARGIN,
48            default_size: Size::new(800.0, 600.0),
49            adopt_scene_size: false,
50            drag_mode: Signal::new(crate::item_handlers::DragMode::RubberBand),
51            handler_snapshot: Rc::new(RefCell::new(Vec::new())),
52            over_claimants: Rc::new(Cell::new(false)),
53            veto_memo: Rc::new(Cell::new(None)),
54            veto_scans: Rc::new(Cell::new(0)),
55            key_probes: Rc::new(Cell::new(0)),
56            snapshot_generation: Rc::new(Cell::new(0)),
57            hit_sync: Rc::new(RefCell::new(super::hit_snapshot::HitSnapshotSync::default())),
58            press_floor: Rc::new(Cell::new(crate::PaintKey::bottom())),
59            hovered_item: Rc::new(Cell::new(None)),
60            pending_tap: Rc::new(Cell::new(None)),
61            last_viewport: Signal::new(Size::new(800.0, 600.0)),
62            pan_x,
63            pan_y,
64            zoom,
65            rotation,
66            bounds_origin_signal,
67            zoom_range_override: Signal::new(Some(DEFAULT_MIN_ZOOM..=DEFAULT_MAX_ZOOM)),
68            pan_bounds_override: Signal::new(None),
69            pan_anim_duration: DEFAULT_PAN_DURATION,
70            zoom_anim_duration: DEFAULT_ZOOM_DURATION,
71            line_height: DEFAULT_LINE_HEIGHT,
72            overscroll_behavior: OverscrollBehavior::Chain,
73            a11y_off_screen_mode: crate::a11y::A11yOffScreenMode::default(),
74            a11y_mode: crate::a11y::A11yMode::default(),
75            self_widget_id: Cell::new(None),
76            interactive: true,
77            view_transform_signal,
78            selection: crate::selection::SceneSelection::new(
79                crate::selection::SceneSelectionMode::None,
80            ),
81            marquee: Rc::new(Cell::new(None)),
82            pending_marquee_commit: Rc::new(RefCell::new(None)),
83            marquee_mode: crate::shape::ItemSelectionMode::default(),
84            drag_target: Rc::new(Cell::new(None)),
85            pending_item_move: Rc::new(Cell::new(None)),
86            lightweight_bounds_snapshot: Rc::new(RefCell::new(Vec::new())),
87            reconcile_dirty: Signal::new(0),
88            appearance_dirty: Signal::new(0),
89            measure_dirty: Signal::new(0),
90            measure_state: Rc::new(RefCell::new(HashMap::new())),
91            cursor_pos: Rc::new(Cell::new(None)),
92            focus_order_callback: None,
93            a11y_nested: false,
94            a11y_label: None,
95            debug_overlay: DebugOverlay::default(),
96            background_paint: None,
97            foreground_paint: None,
98            item_cache: Rc::new(RefCell::new(crate::cache::ItemCoordinateCache::new())),
99            published_visible_region: Rc::new(Cell::new(Rect::ZERO)),
100            child_keys: Rc::new(RefCell::new(HashMap::new())),
101            interleaved_nodes: HashMap::new(),
102            wet_layer: None,
103            wet_node: None,
104            wet_mount: None,
105            _item_cache_observer: RefCell::new(None),
106            _a11y_observer: RefCell::new(None),
107            last_at_version: None,
108            dynamic_churning: false,
109            magnetism: None,
110            port_drag: Rc::new(RefCell::new(None)),
111            item_snap: Rc::new(RefCell::new(None)),
112            magnet_connect_mode: Rc::new(Cell::new(false)),
113            magnet_focus: Rc::new(Cell::new(None)),
114            magnet_pending: Rc::new(Cell::new(None)),
115            transform: None,
116            transform_rt: Rc::new(crate::transform_session::TransformRuntime::default()),
117        }
118    }
119
120    /// Configure selection behavior. Default
121    /// [`SceneSelectionMode::None`](crate::SceneSelectionMode::None) —
122    /// click and marquee do nothing. Set to `Single` for
123    /// at-most-one selection (click replaces) or `Multi` for
124    /// multi-select with marquee box-select, Ctrl+click toggle,
125    /// and Ctrl+drag additive marquee.
126    pub fn selection_mode(mut self, mode: crate::selection::SceneSelectionMode) -> Self {
127        self.selection = crate::selection::SceneSelection::new(mode);
128        self
129    }
130
131    /// Which rule the rubber band picks items by.
132    ///
133    /// Default [`ItemSelectionMode::IntersectsItemShape`] —
134    /// Qt's default, and the one that makes a rubber band agree with a click
135    /// about what an item is: a band that merely grazes a connector's bounding
136    /// box no longer selects the connector, it has to actually cross the
137    /// stroke. Pass
138    /// [`IntersectsItemBoundingRect`](crate::ItemSelectionMode::IntersectsItemBoundingRect)
139    /// for the looser box test, which is what the marquee did before
140    /// `ItemShape` existed.
141    pub fn marquee_selection_mode(mut self, mode: crate::shape::ItemSelectionMode) -> Self {
142        self.marquee_mode = mode;
143        self
144    }
145
146    /// Borrow the SceneView's [`SceneSelection`](crate::SceneSelection).
147    /// Use this from external code to bind to the selection signal,
148    /// query selected ids, or call `select_one` / `clear` /
149    /// `replace` programmatically.
150    pub fn selection(&self) -> &crate::selection::SceneSelection {
151        &self.selection
152    }
153
154    /// Install the per-view heavyweight builder for `Delegated` items
155    /// (those added via [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item)).
156    /// The closure receives the item's type-erased payload and its [`ItemId`]
157    /// and returns the widget to materialise in **this** view's arena.
158    /// Prefer the typed [`delegate_typed`](Self::delegate_typed) wrapper.
159    pub fn delegate(
160        mut self,
161        f: impl Fn(&dyn std::any::Any, ItemId) -> Box<dyn Widget> + 'static,
162    ) -> Self {
163        self.delegate = Some(Rc::new(move |payload, id| Some(f(payload, id))));
164        self
165    }
166
167    /// Typed convenience over [`delegate`](Self::delegate): downcasts the
168    /// payload to `P` before calling `f`. A downcast miss debug-asserts and
169    /// skips the item (no widget is materialised) in release.
170    pub fn delegate_typed<P: 'static>(
171        mut self,
172        f: impl Fn(&P, ItemId) -> Box<dyn Widget> + 'static,
173    ) -> Self {
174        self.delegate = Some(Rc::new(move |payload, id| {
175            match payload.downcast_ref::<P>() {
176                Some(typed) => Some(f(typed, id)),
177                None => {
178                    debug_assert!(
179                        false,
180                        "SceneView delegate_typed: payload for {id:?} is not a {}",
181                        std::any::type_name::<P>()
182                    );
183                    None
184                }
185            }
186        }));
187        self
188    }
189
190    /// Replace this view's selection with a (typically shared) one. Pass the
191    /// same [`SceneSelection`](crate::SceneSelection) clone to several views so
192    /// they select together; capture its `selection_signal()` in your delegate
193    /// to highlight selected items reactively (no rebuild). Distinct from the
194    /// [`selection()`](Self::selection) getter; supersedes any
195    /// [`selection_mode`](Self::selection_mode) set earlier.
196    pub fn selection_model(mut self, selection: crate::selection::SceneSelection) -> Self {
197        self.selection = selection;
198        self
199    }
200
201    /// A clone of this view's [`SceneModel`] handle — for handler closures
202    /// that mutate the scene (every mutator is `&self`) or wire additional views.
203    pub fn model(&self) -> SceneModel {
204        self.model.clone()
205    }
206
207    /// Borrow this view's [`SceneModel`] handle.
208    pub fn model_ref(&self) -> &SceneModel {
209        &self.model
210    }
211
212    /// Drain any pending marquee commit synchronously. Normal
213    /// per-frame use never needs this — `place_children` consumes
214    /// the pending commit at the start of every layout pass. Tests
215    /// that drive on_drag without a follow-up layout call this to
216    /// materialise the box-select result.
217    pub fn flush_marquee_commit(&self) -> bool {
218        let pending = self.pending_marquee_commit.borrow_mut().take();
219        if let Some((region, mode, additive)) = pending {
220            self.selection.commit_marquee_region(
221                &self.model.0.borrow(),
222                &region,
223                mode,
224                self.view_scale(),
225                additive,
226            );
227            self.marquee.set(None);
228            true
229        } else {
230            false
231        }
232    }
233
234    /// Drain any pending drag-to-move commit by translating the
235    /// dragged item's `local_pos` by the queued delta. Descendants
236    /// follow automatically: their `local_pos` is unchanged but
237    /// their `scene_pos` derives from the parent's chain.
238    pub fn flush_pending_item_move(&mut self) -> bool {
239        if let Some((target_id, delta)) = self.pending_item_move.take() {
240            // The whole selection when the grab was on a selected item — see
241            // `SceneView::drag_group`, which the paint feedback reads too.
242            for id in self.drag_group(target_id) {
243                if let Some(local_pos) = self.model.local_pos(id) {
244                    let new_local_pos = Point::new(local_pos.x + delta.x, local_pos.y + delta.y);
245                    self.model.set_local_pos(id, new_local_pos);
246                }
247            }
248            self.drag_target.set(None);
249            true
250        } else {
251            false
252        }
253    }
254
255    /// Drain a committed transform, applying its delta through one
256    /// [`Scene::apply_transform_delta`](crate::Scene::apply_transform_delta).
257    ///
258    /// The same drain `build()` runs; the public form exists for the headless
259    /// driver, exactly like [`flush_pending_item_move`](Self::flush_pending_item_move)
260    /// and [`flush_marquee_commit`](Self::flush_marquee_commit). Returns whether
261    /// anything was pending.
262    pub fn flush_pending_transform(&mut self) -> bool {
263        let pending = self.transform_rt.pending_commit.borrow_mut().take();
264        match pending {
265            Some((roots, delta)) => {
266                self.model.apply_transform_delta(&roots, &delta);
267                true
268            }
269            None => false,
270        }
271    }
272
273    /// Disable user-driven navigation: pinch and keyboard handlers are not
274    /// registered, the scroll handler's wheel arm returns early, and the
275    /// SceneView is not made focusable. The `on_scroll` slot itself stays
276    /// registered, because it also carries a descendant's `ScrollIntoView`
277    /// reveal — a focused caret inside an embedded card is entitled to be on
278    /// screen whether or not the *user* may drive the camera. Programmatic
279    /// [`pan_to`](Self::pan_to) /
280    /// [`zoom_to`](Self::zoom_to) / [`fit_to_content`](Self::fit_to_content)
281    /// still work — this gates only user input.
282    ///
283    /// Use this for **outer** SceneViews in nested chart-style
284    /// patterns: an outer locked SceneView holds axis chrome
285    /// (`TextItem`s reading the inner's pan/zoom signals via
286    /// [`view_transform_signal`](Self::view_transform_signal)),
287    /// an inner interactive SceneView holds the data and accepts
288    /// pan/zoom from the user. Default: interactive (`true`).
289    pub fn interactive(mut self, interactive: bool) -> Self {
290        self.interactive = interactive;
291        self
292    }
293
294    /// Mark this SceneView as logically nested inside another
295    /// SceneView. Affects only the AT walker — the inner
296    /// SceneView reports `Role::Region` instead of the default
297    /// `Role::Pane`, so screen readers don't announce a
298    /// redundant top-level landmark for what's logically a sub-
299    /// region. Pair with [`a11y_label`](Self::a11y_label) to give
300    /// the inner region a useful announce name.
301    ///
302    /// Use case: chart-style nested scenes (outer SceneView holds
303    /// axis chrome, inner SceneView holds data) — the inner one
304    /// should announce as "Data area" or similar, not as another
305    /// "Pane" sibling to the outer.
306    ///
307    /// Default `false`. Apps explicitly set this when they know
308    /// they're nesting; the framework doesn't introspect the
309    /// widget tree to detect nesting automatically (deliberately
310    /// kept declarative — the visual layout doesn't always match
311    /// logical nesting).
312    pub fn nested_a11y(mut self, nested: bool) -> Self {
313        self.a11y_nested = nested;
314        self
315    }
316
317    /// Set the AT label announced as this SceneView's own name.
318    /// Particularly useful for nested SceneViews via
319    /// [`nested_a11y`](Self::nested_a11y), where the inner
320    /// region should have a domain-specific name (e.g. "Chart
321    /// data area"). Default `None` — the SceneView has no
322    /// explicit AT name.
323    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
324        let ls: LocalizedString = label.into();
325        self.a11y_label = Some(ls);
326        self
327    }
328
329    /// Whether the SceneView is currently marked as logically
330    /// nested. Read-only accessor for tests / diagnostics.
331    pub fn is_nested(&self) -> bool {
332        self.a11y_nested
333    }
334
335    /// Configure visual debug overlays. Default: all flags off.
336    /// Pass [`DebugOverlay::ALL`] to enable every overlay or
337    /// construct a custom config:
338    ///
339    /// ```
340    /// # use teksilo_scene::{Scene, SceneView, DebugOverlay};
341    /// # let scene = Scene::new();
342    /// let _view = SceneView::new(scene)
343    ///     .debug_overlay(DebugOverlay {
344    ///         item_bounds: true,
345    ///         viewport: true,
346    ///         ..Default::default()
347    ///     });
348    /// ```
349    ///
350    /// Intended for development only — overlay paint is cheap but
351    /// not free; ship with the default (off) config.
352    pub fn debug_overlay(mut self, overlay: DebugOverlay) -> Self {
353        self.debug_overlay = overlay;
354        self
355    }
356
357    /// Read-only accessor for the active debug overlay config.
358    pub fn current_debug_overlay(&self) -> DebugOverlay {
359        self.debug_overlay
360    }
361
362    /// Install a custom focus-order callback. When set,
363    /// [`next_focus`](Self::next_focus) /
364    /// [`previous_focus`](Self::previous_focus) route through the
365    /// closure instead of falling back to scene insertion order.
366    ///
367    /// Apps wire this to a Tab / Shift+Tab handler in their root
368    /// shortcut/action map. Typical implementations:
369    ///
370    /// - **Graph editor:** walk outgoing-port connections from the
371    ///   current node, return the connected-node `ItemId`.
372    /// - **Corkboard with Acts:** walk a parallel `BTreeMap<ActId,
373    ///   Vec<CardId>>` declared by the app and Tab through cards in
374    ///   story order, not reading order.
375    /// - **Timeline:** sort items by `start_time`, return the next.
376    ///
377    /// The callback receives the full [`Scene`] (read-only), the
378    /// requested [`FocusDirection`], and the currently focused item
379    /// (`None` on the first Tab into the scene). Return `None` to
380    /// signal "no next item" (the framework can then advance focus
381    /// outside the SceneView).
382    ///
383    /// Calling [`next_focus`](Self::next_focus) /
384    /// [`previous_focus`](Self::previous_focus) without a callback
385    /// installed walks scene insertion order — adequate for simple
386    /// scenes; replace as needed.
387    pub fn focus_order<F>(mut self, callback: F) -> Self
388    where
389        F: Fn(&Scene, FocusDirection, Option<ItemId>) -> Option<ItemId> + 'static,
390    {
391        self.focus_order_callback = Some(Rc::new(callback));
392        self
393    }
394
395    /// Compute the next item the focus should advance to in the
396    /// given direction. If a [`focus_order`](Self::focus_order)
397    /// callback is installed, routes through it; otherwise falls
398    /// back to scene insertion order — `Forward` returns the item
399    /// after `current` (or the first if `current` is `None`),
400    /// `Backward` returns the previous (or the last if `current`
401    /// is `None`).
402    pub fn focus_in_direction(
403        &self,
404        direction: FocusDirection,
405        current: Option<ItemId>,
406    ) -> Option<ItemId> {
407        if let Some(cb) = &self.focus_order_callback {
408            return cb(&self.model.0.borrow(), direction, current);
409        }
410        let ids = self.scene().ids();
411        if ids.is_empty() {
412            return None;
413        }
414        match (direction, current) {
415            (FocusDirection::Forward, None) => ids.first().copied(),
416            (FocusDirection::Backward, None) => ids.last().copied(),
417            (FocusDirection::Forward, Some(cur)) => ids
418                .iter()
419                .position(|id| *id == cur)
420                .and_then(|i| ids.get(i + 1).copied()),
421            (FocusDirection::Backward, Some(cur)) => {
422                ids.iter().position(|id| *id == cur).and_then(|i| {
423                    if i == 0 {
424                        None
425                    } else {
426                        ids.get(i - 1).copied()
427                    }
428                })
429            }
430        }
431    }
432
433    /// Convenience: forward-Tab traversal. See
434    /// [`focus_in_direction`](Self::focus_in_direction).
435    pub fn next_focus(&self, current: Option<ItemId>) -> Option<ItemId> {
436        self.focus_in_direction(FocusDirection::Forward, current)
437    }
438
439    /// Convenience: backward-Tab (Shift+Tab) traversal. See
440    /// [`focus_in_direction`](Self::focus_in_direction).
441    pub fn previous_focus(&self, current: Option<ItemId>) -> Option<ItemId> {
442        self.focus_in_direction(FocusDirection::Backward, current)
443    }
444
445    /// Live `Signal<f32>` for the X pan offset. Use this from a
446    /// parent scene (or any reactive consumer) to derive values
447    /// that follow the SceneView's pan — typically axis-label
448    /// text in a chart-style outer SceneView.
449    pub fn pan_x_signal(&self) -> Signal<f32> {
450        self.pan_x.clone()
451    }
452
453    /// Live `Signal<f32>` for the Y pan offset.
454    pub fn pan_y_signal(&self) -> Signal<f32> {
455        self.pan_y.clone()
456    }
457
458    /// Live `Signal<f32>` for the zoom factor.
459    pub fn zoom_signal(&self) -> Signal<f32> {
460        self.zoom.clone()
461    }
462
463    /// Live `Signal<f32>` for the rotation in radians.
464    pub fn rotation_signal(&self) -> Signal<f32> {
465        self.rotation.clone()
466    }
467
468    /// Live `Signal<Transform2D>` for the composed view transform
469    /// (pan + zoom + rotation + bounds-origin). Folds in the
470    /// `bounds.origin` contribution so reactive consumers see the
471    /// exact transform the renderer applies. Updated whenever any
472    /// of the underlying signals change. Use this when the
473    /// consumer needs the full matrix (e.g. converting a screen
474    /// point to scene coords from outside the SceneView).
475    pub fn view_transform_signal(&self) -> Signal<Transform2D> {
476        self.view_transform_signal.clone()
477    }
478
479    /// Override the [`A11yMode`](crate::a11y::A11yMode) for this
480    /// SceneView. Default is `Cooperative` — the visual scene
481    /// layout drives AT emission unless explicitly overridden via
482    /// [`Scene::set_a11y_parent`](crate::Scene::set_a11y_parent).
483    /// Switch to `StrictlyParallel` when your app's AT shape is
484    /// fundamentally different from its visual layout: items
485    /// without a declared logical parent are then suppressed from
486    /// the AT tree, and the app declares every node it wants AT
487    /// users to reach.
488    pub fn a11y_mode(mut self, mode: crate::a11y::A11yMode) -> Self {
489        self.a11y_mode = mode;
490        self
491    }
492
493    /// How far past the viewport edge, in **screen** pixels, a heavyweight
494    /// card stays live. Default [`DEFAULT_RETENTION_MARGIN`] (96).
495    ///
496    /// A card outside the *retention region* — the viewport grown by this
497    /// margin, unioned with whatever
498    /// [`a11y_off_screen_mode`](Self::a11y_off_screen_mode) asks to be
499    /// listed — is parked dormant. It leaves paint, the layout recursion, the
500    /// AccessKit tree and the Tab ring, and keeps its focus, text and
501    /// animation state for when the camera brings it back.
502    ///
503    /// **It is a lifecycle knob, not an accessibility one.** Raising it does
504    /// not offer assistive technology more of the scene: how much of it a
505    /// screen reader is enumerated is decided by
506    /// [`a11y_off_screen_mode`](Self::a11y_off_screen_mode) alone. A card in
507    /// the margin band but outside that mode's region is alive and unlisted —
508    /// laid out, Tab-reachable, holding its state, absent from the AccessKit
509    /// tree. Landing focus on it publishes it, in the same pass.
510    ///
511    /// **Screen pixels, not scene units**, converted through the current zoom
512    /// each pass, so the margin is a constant on-screen distance at every zoom
513    /// level. That is what it has to be: a pan covers screen distance, not
514    /// scene distance, and the margin's job is to have woken a card before the
515    /// camera reaches it. At zoom 0.1 the default is 960 scene units.
516    ///
517    /// Three regions, not one, and they are layered: the *tight* viewport
518    /// decides who is laid out at full size versus collapsed to zero
519    /// (unchanged, zero margin), the accessibility region decides who is
520    /// enumerated, and this widest one decides who is dormant. Each contains
521    /// the one before it by construction — so a card being laid out is never
522    /// also parked, and the AT walk is never asked to describe a card the
523    /// arena has parked.
524    ///
525    /// Raising this keeps more cards live — more Tab stops, more layout
526    /// recursion, more state held; lowering it parks closer to the edge. Zero
527    /// is legal and means "park at the viewport edge"; a card entering is
528    /// still woken and laid out in the same pass, so nothing is ever a frame
529    /// late, but every crossing then pays a wake.
530    pub fn retention_margin(mut self, screen_px: f32) -> Self {
531        self.retention_margin = screen_px.max(0.0);
532        self
533    }
534
535    /// The current retention margin in screen pixels.
536    pub fn current_retention_margin(&self) -> f32 {
537        self.retention_margin
538    }
539
540    /// Override the off-screen visibility policy for the AT walker — how much
541    /// of a scene that runs past the viewport a screen reader is offered.
542    ///
543    /// Default: `ViewportPlusN { n: 1 }` — everything inside the viewport plus
544    /// a one-screen margin on each side. `AllItems` for small scenes where AT
545    /// users want a complete table of contents (nothing is ever parked in that
546    /// mode, so it costs what it promises); `ViewportOnly` for very large
547    /// scenes, where listing off-screen content would bury an AT client.
548    ///
549    /// This governs **both** tiers — lightweight items and heavyweight cards —
550    /// and it is the only thing that does.
551    /// [`retention_margin`](Self::retention_margin) can keep a card alive past
552    /// this region, for the camera's sake, without adding it here.
553    pub fn a11y_off_screen_mode(mut self, mode: crate::a11y::A11yOffScreenMode) -> Self {
554        self.a11y_off_screen_mode = mode;
555        self
556    }
557
558    /// Override the size used when the parent doesn't propose one on
559    /// an axis. Defaults to 800×600 logical pixels.
560    pub fn default_size(mut self, w: f32, h: f32) -> Self {
561        self.default_size = Size::new(w, h);
562        if self.last_viewport.get() != self.default_size {
563            self.last_viewport.set(self.default_size);
564        }
565        self
566    }
567
568    /// When set, the view's `layout_response` returns the scene's
569    /// `scene_rect_extent` as its own wanted size — the view sizes
570    /// itself to its scene rather than to `default_size`. Pairs
571    /// naturally with [`Scene::pan_axes`] / [`Scene::zoomable`]
572    /// to embed bounded, non-navigable scenes inline (mini diagrams,
573    /// fixed corkboards). Default `false`.
574    pub fn adopt_scene_size(mut self, on: bool) -> Self {
575        self.adopt_scene_size = on;
576        self
577    }
578
579    /// Configure how left-mouse drag-on-canvas behaves. Default
580    /// [`DragMode::RubberBand`](crate::DragMode) — drag-on-an-item
581    /// moves it (when `IS_DRAGGABLE`), drag-on-empty-space creates
582    /// a marquee. `DragMode::ScrollHandDrag` makes left-drag
583    /// pan the view unconditionally; `DragMode::NoDrag` disables
584    /// the on-drag handler entirely.
585    ///
586    /// Accepts a static `DragMode` — which sets the current value on the
587    /// view's internal signal — or a `Signal<DragMode>` (via
588    /// `impl Into<Prop<DragMode>>`), which **replaces** the internal signal
589    /// with the app-owned one so a toolbar can hold the same handle and
590    /// toggle Hand vs Select vs NoDrag at runtime. To stop sharing, pass a
591    /// fresh `Signal::new(mode)`.
592    pub fn drag_mode(mut self, mode: impl Into<Prop<crate::item_handlers::DragMode>>) -> Self {
593        match mode.into() {
594            Prop::Static(m) => self.drag_mode.set(m),
595            Prop::Bound(sig) => self.drag_mode = sig,
596        }
597        self
598    }
599
600    /// Compose the derived view-transform signal from the four view-state
601    /// signals plus the bounds origin. Coalesced so a simultaneous pan/zoom/
602    /// rotation tick registers a single binding per observing widget (instead
603    /// of five). Called in `new` and re-called by
604    /// [`view_state`](Self::view_state) after the signals are swapped.
605    fn compose_view_transform(
606        pan_x: &Signal<f32>,
607        pan_y: &Signal<f32>,
608        zoom: &Signal<f32>,
609        rotation: &Signal<f32>,
610        bounds_origin: &Signal<Vec2>,
611    ) -> Signal<Transform2D> {
612        pan_x
613            .zip3(pan_y, zoom)
614            .zip(rotation)
615            .zip(bounds_origin)
616            .map_coalesced(|(((px, py, z), r), bo)| {
617                compose_view(Vec2::new(*px + bo.x, *py + bo.y), *z, *r)
618            })
619    }
620
621    /// Replace the view's pan / zoom / rotation signals with app-owned ones.
622    ///
623    /// The four view-state signals become the app's to hold, share, and
624    /// persist — so view state survives a *rebuild-from-state* (a wrapper that
625    /// reconstructs the `Scene` + `SceneView` keeps the same signals and the
626    /// viewport doesn't jump back to the origin), a "Reset View" button can
627    /// snap them, and two views could share one camera. The derived
628    /// [`view_transform_signal`](Self::view_transform_signal) is recomposed
629    /// from the injected signals.
630    ///
631    /// Must be called before the view is added to the tree (like the other
632    /// builder methods) — `build()` reads `view_transform_signal` once.
633    pub fn view_state(
634        mut self,
635        pan_x: Signal<f32>,
636        pan_y: Signal<f32>,
637        zoom: Signal<f32>,
638        rotation: Signal<f32>,
639    ) -> Self {
640        // Recompose first (borrows the new signals), then move them into self.
641        self.view_transform_signal = Self::compose_view_transform(
642            &pan_x,
643            &pan_y,
644            &zoom,
645            &rotation,
646            &self.bounds_origin_signal,
647        );
648        self.pan_x = pan_x;
649        self.pan_y = pan_y;
650        self.zoom = zoom;
651        self.rotation = rotation;
652        self
653    }
654
655    /// Seed the initial pan offset (logical pixels). The view keeps ownership
656    /// of the signals; for app-owned signals use [`view_state`](Self::view_state).
657    pub fn initial_pan(self, x: f32, y: f32) -> Self {
658        self.pan_x.set(x);
659        self.pan_y.set(y);
660        self
661    }
662
663    /// Seed the initial zoom factor (clamped to the active zoom range).
664    pub fn initial_zoom(self, zoom: f32) -> Self {
665        let gated = self.gate_zoom_target(zoom);
666        self.zoom.set(gated);
667        self
668    }
669
670    /// Seed the initial rotation (radians).
671    pub fn initial_rotation(self, radians: f32) -> Self {
672        self.rotation.set(radians);
673        self
674    }
675
676    /// Reactive accessor for the drag mode. Useful for toolbars
677    /// that need to read the current mode (e.g. to highlight the
678    /// active tool button) and write to it.
679    pub fn drag_mode_signal(&self) -> Signal<crate::item_handlers::DragMode> {
680        self.drag_mode.clone()
681    }
682
683    /// Install a closure painted **before** the items walk. The
684    /// canvas already has the view-transform scope pushed, so the
685    /// closure paints in scene coords. The `Rect` argument is the
686    /// scene-coord visible region — useful for tiled backgrounds
687    /// (graph-paper grids, ruled lines, dot grids) so the closure
688    /// only emits geometry the user can actually see.
689    ///
690    /// ```ignore
691    /// SceneView::new(scene).background(|canvas, _ctx, region| {
692    ///     // Draw a 50-unit grid covering only the visible region.
693    ///     let step = 50.0;
694    ///     let x0 = (region.x / step).floor() * step;
695    ///     let mut x = x0;
696    ///     while x < region.x + region.width {
697    ///         canvas.draw_line(/* ... */);
698    ///         x += step;
699    ///     }
700    /// })
701    /// ```
702    pub fn background<F>(mut self, paint: F) -> Self
703    where
704        F: Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect) + 'static,
705    {
706        self.background_paint = Some(Rc::new(paint));
707        self
708    }
709
710    /// Install a closure painted **after** the items walk and the
711    /// marquee, but before any debug overlay. Same coordinate
712    /// conventions as [`background`](Self::background). Used for
713    /// scene-coord chrome that should ride over content (rulers,
714    /// snap-line indicators, drop hints).
715    pub fn foreground<F>(mut self, paint: F) -> Self
716    where
717        F: Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect) + 'static,
718    {
719        self.foreground_paint = Some(Rc::new(paint));
720        self
721    }
722
723    /// Install a [`WetLayer`](crate::WetLayer) — a surface for content being
724    /// authored right now, which repaints without taking the scene's item
725    /// bands with it.
726    ///
727    /// Its node is always the **last** child of this view, so wet content sits
728    /// above every card and every
729    /// [`Interleaved`](crate::SceneLayer::Interleaved) item, and under the
730    /// `Over` band and the view's own chrome (marquee, magnet feedback,
731    /// transform frame, debug overlay).
732    ///
733    /// Unlike [`foreground`](Self::foreground), which runs inside the view's
734    /// own `post_paint` and therefore cannot be invalidated without repainting
735    /// the whole `Under` band with it, this is a node of its own:
736    /// [`WetLayer::request_repaint`](crate::WetLayer::request_repaint) marks it
737    /// and nothing else.
738    ///
739    /// Paint-only, hidden from assistive technology and transparent to the
740    /// pointer — see [`WetLayer`](crate::WetLayer).
741    ///
742    /// The same layer may be installed in **several** views: each mounts its
743    /// own node and paints the same painter, the way several views share one
744    /// [`SceneModel`]. Mounting it in a second view does not
745    /// unmount it from the first.
746    pub fn wet_layer(mut self, layer: crate::WetLayer) -> Self {
747        self.wet_layer = Some(layer);
748        self
749    }
750
751    /// Enable magnetism on this view with the given
752    /// [`MagnetismConfig`].
753    ///
754    /// Once installed, this view's lightweight item drags snap their
755    /// magnets onto compatible magnets on other items, magnet handles
756    /// become grabbable for port-drag wires, the keyboard connect flow
757    /// (the config's connect key) is available while the view is
758    /// focused, magnet markers paint, and each enabled magnet is
759    /// emitted as a synthetic AT node. A view with no magnetism config
760    /// ignores magnets entirely.
761    pub fn magnetism(mut self, config: crate::magnet::MagnetismConfig) -> Self {
762        self.magnetism = Some(Rc::new(config));
763        self
764    }
765
766    /// Install the **selection transform controller** on this view: a frame
767    /// with resize and rotate handles drawn around the selection, a group move,
768    /// a keyboard route, and accessibility nodes for all of it.
769    ///
770    /// Nothing about the view changes until this is called — with no controller
771    /// the pointer rules are exactly what they were.
772    ///
773    /// **Where the gesture may land** is not this controller's business: it is
774    /// the scene's, through
775    /// [`SceneModel::set_geometry_constraint`](crate::SceneModel::set_geometry_constraint),
776    /// which every gesture here consults on every sample. Snap-to-grid, axis
777    /// lock and page clamping live there, once for the document, rather than
778    /// once per view.
779    ///
780    /// # What a gesture writes, and when
781    ///
782    /// Nothing, until the release. The session is per-view and model-free: it
783    /// reads [`SceneSelection`](crate::SceneSelection), paints a preview, and
784    /// then writes the scene **once**, through a single
785    /// [`Scene::apply_transform_delta`](crate::Scene::apply_transform_delta).
786    /// So Esc is "drop the session" with no rollback to get wrong, a ten-item
787    /// selection emits no `ItemChange` at all until it lands, and an app that
788    /// wants the edit to be reversible has exactly one call to record — from
789    /// [`TransformConfig::on_end`](crate::TransformConfig::on_end). The history itself is the data layer's, not
790    /// this crate's.
791    ///
792    /// # Which handles appear
793    ///
794    /// Exactly the operations that will be honoured, never more:
795    ///
796    /// | Operation | Flag | Offered when |
797    /// | --- | --- | --- |
798    /// | Move | `IS_DRAGGABLE` | **any** selection root carries it (the others stay put) |
799    /// | Resize | `IS_RESIZABLE` | **every** selection root carries it |
800    /// | Rotate | `IS_ROTATABLE` | **every** selection root carries it *and* is lightweight |
801    ///
802    /// Resize and rotate demand unanimity because a partial one would tear the
803    /// selection apart — the locked members would stand still while the rest
804    /// scaled, and the frame would stop describing what it is drawn around. A
805    /// move can be partial because that is what every editor does with a locked
806    /// object.
807    ///
808    /// The flags are **honoured, not bypassed**: a default `ItemFlags` carries
809    /// `IS_SELECTABLE` and not `IS_DRAGGABLE`, so selecting a decorative item
810    /// does not quietly make it draggable.
811    ///
812    /// # Reaching the chrome over a heavyweight card
813    ///
814    /// The frame is drawn [`padding`](crate::TransformConfig::padding) **outside** the
815    /// selection, so the band and every handle sit on pixels no card owns. That
816    /// matters because a card that claims its own press — one calling
817    /// `capture_pointer`, or carrying its own `on_drag`, which is every
818    /// `Splitter` handle, `SpinBox` step button and `TextInput` selection drag —
819    /// wins that press by design, and this view never sees the gesture. So
820    /// **body drag over a card is best-effort and the frame band is the route
821    /// that always works.** Over the lightweight tier, and over a card that
822    /// claims nothing, both work.
823    ///
824    /// # Keyboard and assistive technology
825    ///
826    /// `t` (see [`TransformConfig::transform_key`](crate::TransformConfig::transform_key)) enters a handle-roving
827    /// mode: `Tab` / `Shift+Tab` move between the handles the frame is
828    /// offering, arrows drive the roved one (`Shift` ×10), `Enter` commits,
829    /// `Esc` cancels, `t` leaves. The frame and each handle are published as
830    /// accessibility nodes with `Increment` / `Decrement`, and both routes go
831    /// through the same session and the same commit — so the non-drag
832    /// alternative makes the same model change the drag makes, structurally.
833    ///
834    /// ```
835    /// # use teksilo_scene::{Scene, SceneView, SceneSelectionMode, TransformConfig};
836    /// let view = SceneView::new(Scene::new())
837    ///     .selection_mode(SceneSelectionMode::Multi)
838    ///     .transform_controller(TransformConfig::new().keep_ratio(true));
839    /// ```
840    pub fn transform_controller(
841        mut self,
842        config: crate::transform_session::TransformConfig,
843    ) -> Self {
844        self.transform = Some(Rc::new(config));
845        self
846    }
847
848    /// The live transform session, for an app-owned inspector, status bar or
849    /// size readout. `None` between gestures. Bind at
850    /// [`BindingLevel::RepaintOnly`].
851    ///
852    /// Republished on every **input** sample — each pointer move, each keyboard
853    /// step, and at the start and end of a gesture. It is deliberately *not*
854    /// republished per frame: during an edge auto-pan the pointer is standing
855    /// still while the view slides under it, so the frame keeps changing with
856    /// no sample to hang a republish on, and a readout bound here shows the
857    /// last sampled numbers until the pointer moves again. What is committed is
858    /// always the live resolution, so this lags the frame rather than
859    /// disagreeing with the result.
860    pub fn transform_session_signal(
861        &self,
862    ) -> Signal<Option<crate::transform_session::TransformSession>> {
863        self.transform_rt.published.clone()
864    }
865
866    /// The reactive enabled signal of the installed transform controller, if
867    /// any — for a toolbar to read or bind a "transform tools" toggle.
868    pub fn transform_enabled_signal(&self) -> Option<Signal<bool>> {
869        self.transform.as_ref().map(|c| c.enabled_signal())
870    }
871
872    /// The reactive enabled signal of the installed magnetism config, if
873    /// any — for a toolbar to read or bind a magnetism on/off toggle.
874    pub fn magnetism_enabled_signal(&self) -> Option<Signal<bool>> {
875        self.magnetism.as_ref().map(|c| c.enabled_signal())
876    }
877
878    /// Drop the cached paint output for `id`. Apps that mutate
879    /// item-internal state without going through a [`Scene`] mutator
880    /// (e.g. a custom item whose paint depends on a private
881    /// `Signal<Color>` that doesn't drive `local_bounds`) call this
882    /// to invalidate. The cache is otherwise dropped automatically
883    /// on `LocalBoundsChanged` / `ItemReplaced` / `AppearanceChanged` /
884    /// `MeasuredSizeChanged` / an `IS_ENABLED` `FlagsChanged` / `Removed` —
885    /// but never on opacity / transform / z / `local_pos`, which are applied
886    /// as wrapping scopes at replay and don't bake into a cached frame.
887    pub fn invalidate_item_cache(&self, id: ItemId) {
888        self.item_cache.borrow_mut().evict(id);
889    }
890
891    /// Number of cached entries currently held. Diagnostic / test
892    /// hook — apps shouldn't normally need this.
893    pub fn item_cache_len(&self) -> usize {
894        self.item_cache.borrow().len()
895    }
896
897    /// Minimum zoom factor (default 0.1×). Applied as a clamp to all
898    /// programmatic and gesture-driven zoom changes via the
899    /// view-level [`zoom_range_override`](Self::zoom_range_override).
900    /// Shim — updates the lower bound of the override range. The
901    /// effective clamp is the intersection of Scene-level
902    /// [`Scene::set_zoom_range`](crate::Scene::set_zoom_range) and
903    /// this override (tightening-only — neither side can loosen).
904    pub fn min_zoom(self, v: f32) -> Self {
905        let lo = v.max(0.0001);
906        let current = self.zoom_range_override.get();
907        let hi = current
908            .as_ref()
909            .map(|r| *r.end())
910            .unwrap_or(DEFAULT_MAX_ZOOM);
911        self.zoom_range_override.set(Some(lo..=hi.max(lo)));
912        self
913    }
914
915    /// Maximum zoom factor (default 10×). Shim — updates the upper
916    /// bound of the override range. See [`min_zoom`](Self::min_zoom).
917    pub fn max_zoom(self, v: f32) -> Self {
918        let current = self.zoom_range_override.get();
919        let lo = current
920            .as_ref()
921            .map(|r| *r.start())
922            .unwrap_or(DEFAULT_MIN_ZOOM);
923        self.zoom_range_override.set(Some(lo..=v.max(lo)));
924        self
925    }
926
927    /// Replace the view-level zoom-range override wholesale.
928    /// `None` clears the override so this view imposes no zoom
929    /// clamp of its own (Scene-level constraints still apply).
930    /// Tightening rule: the effective clamp is the intersection
931    /// with `Scene::current_zoom_range()` — neither can loosen.
932    pub fn zoom_range_override(self, range: Option<std::ops::RangeInclusive<f32>>) -> Self {
933        self.zoom_range_override.set(range);
934        self
935    }
936
937    /// Reactive accessor for the view-level zoom-range override.
938    /// Use this to mutate the override at runtime (e.g. from a
939    /// toolbar). Mutations take effect on the next gesture.
940    pub fn zoom_range_override_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>> {
941        self.zoom_range_override.clone()
942    }
943
944    /// View-level *tightening* override on pan bounds, in scene
945    /// coords. The effective clamp at gesture-time is the rect
946    /// intersection with `Scene::current_pan_bounds()` — view
947    /// overrides cannot loosen what the `Scene` declares. `None`
948    /// (default) means no view-side clamp.
949    pub fn pan_bounds_override(self, bounds: Option<Rect>) -> Self {
950        self.pan_bounds_override.set(bounds);
951        self
952    }
953
954    /// Reactive accessor for the view-level pan-bounds override.
955    /// Use this to mutate the override at runtime (e.g. dynamically
956    /// shrinking the navigable area). Mutations take effect on the
957    /// next gesture.
958    pub fn pan_bounds_override_signal(&self) -> Signal<Option<Rect>> {
959        self.pan_bounds_override.clone()
960    }
961
962    /// Logical pixels of pan applied per scroll-wheel line notch.
963    /// Defaults to 16 px (matches `ScrollArea`).
964    pub fn line_height(mut self, px: f32) -> Self {
965        self.line_height = px.max(0.0);
966        self
967    }
968
969    /// Whether a wheel the scene can't absorb (already clamped at its
970    /// `pan_bounds`) chains to an ancestor scrollable
971    /// ([`OverscrollBehavior::Chain`], the default — matches the widget
972    /// scrollables) or is contained ([`OverscrollBehavior::Contain`]). Use
973    /// `Contain` for a tightly-bounded scene embedded in a scroll view that
974    /// should never steal the scene's wheel.
975    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
976        self.overscroll_behavior = behavior;
977        self
978    }
979
980    /// Wrap this view in a [`SceneScrollView`](crate::SceneScrollView), adding
981    /// draggable scroll bars with the widget-tier `ScrollArea`'s options (mode,
982    /// per-axis policy, thickness). The bars track the camera and drive panning;
983    /// native wheel / drag panning — and its smoothing — keeps working.
984    ///
985    /// Configure the result with the `SceneScrollView` builder methods:
986    ///
987    /// ```no_run
988    /// # use teksilo_scene::{Scene, SceneView, ScrollBarMode, ScrollBarPolicy};
989    /// let scrollable = SceneView::new(Scene::new())
990    ///     .with_scroll_bars()
991    ///     .scroll_bar_mode(ScrollBarMode::Overlay)
992    ///     .vertical_policy(ScrollBarPolicy::AsNeeded);
993    /// # let _ = scrollable;
994    /// ```
995    pub fn with_scroll_bars(self) -> crate::SceneScrollView {
996        crate::SceneScrollView::new(self)
997    }
998}