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`, `a11y_bounds_space`, `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            default_size: Size::new(800.0, 600.0),
46            adopt_scene_size: false,
47            drag_mode: Signal::new(crate::item_handlers::DragMode::RubberBand),
48            handler_snapshot: Rc::new(RefCell::new(Vec::new())),
49            hovered_item: Rc::new(Cell::new(None)),
50            pending_tap: Rc::new(Cell::new(None)),
51            last_viewport: Signal::new(Size::new(800.0, 600.0)),
52            pan_x,
53            pan_y,
54            zoom,
55            rotation,
56            bounds_origin_signal,
57            zoom_range_override: Signal::new(Some(DEFAULT_MIN_ZOOM..=DEFAULT_MAX_ZOOM)),
58            pan_bounds_override: Signal::new(None),
59            pan_anim_duration: DEFAULT_PAN_DURATION,
60            zoom_anim_duration: DEFAULT_ZOOM_DURATION,
61            line_height: DEFAULT_LINE_HEIGHT,
62            overscroll_behavior: OverscrollBehavior::Chain,
63            a11y_off_screen_mode: crate::a11y::A11yOffScreenMode::default(),
64            a11y_mode: crate::a11y::A11yMode::default(),
65            self_widget_id: Cell::new(None),
66            interactive: true,
67            view_transform_signal,
68            selection: crate::selection::SceneSelection::new(
69                crate::selection::SceneSelectionMode::None,
70            ),
71            marquee: Rc::new(Cell::new(None)),
72            pending_marquee_commit: Rc::new(Cell::new(None)),
73            drag_target: Rc::new(Cell::new(None)),
74            pending_item_move: Rc::new(Cell::new(None)),
75            lightweight_bounds_snapshot: Rc::new(RefCell::new(Vec::new())),
76            reconcile_dirty: Signal::new(0),
77            appearance_dirty: Signal::new(0),
78            cursor_pos: Rc::new(Cell::new(None)),
79            focus_order_callback: None,
80            a11y_nested: false,
81            a11y_label: None,
82            a11y_bounds_space: crate::a11y::A11yBoundsSpace::default(),
83            debug_overlay: DebugOverlay::default(),
84            background_paint: None,
85            foreground_paint: None,
86            item_cache: Rc::new(RefCell::new(crate::cache::ItemCoordinateCache::new())),
87            _item_cache_observer: RefCell::new(None),
88            _a11y_observer: RefCell::new(None),
89            last_at_version: None,
90            dynamic_churning: false,
91            magnetism: None,
92            port_drag: Rc::new(RefCell::new(None)),
93            item_snap: Rc::new(RefCell::new(None)),
94            magnet_connect_mode: Rc::new(Cell::new(false)),
95            magnet_focus: Rc::new(Cell::new(None)),
96            magnet_pending: Rc::new(Cell::new(None)),
97        }
98    }
99
100    /// Configure selection behavior. Default
101    /// [`SceneSelectionMode::None`](crate::SceneSelectionMode::None) —
102    /// click and marquee do nothing. Set to `Single` for
103    /// at-most-one selection (click replaces) or `Multi` for
104    /// multi-select with marquee box-select, Ctrl+click toggle,
105    /// and Ctrl+drag additive marquee.
106    pub fn selection_mode(mut self, mode: crate::selection::SceneSelectionMode) -> Self {
107        self.selection = crate::selection::SceneSelection::new(mode);
108        self
109    }
110
111    /// Borrow the SceneView's [`SceneSelection`](crate::SceneSelection).
112    /// Use this from external code to bind to the selection signal,
113    /// query selected ids, or call `select_one` / `clear` /
114    /// `replace` programmatically.
115    pub fn selection(&self) -> &crate::selection::SceneSelection {
116        &self.selection
117    }
118
119    /// Install the per-view heavyweight builder for `Delegated` items
120    /// (those added via [`SceneModel::add_widget_item`](crate::SceneModel::add_widget_item)).
121    /// The closure receives the item's type-erased payload and its [`ItemId`]
122    /// and returns the widget to materialise in **this** view's arena.
123    /// Prefer the typed [`delegate_typed`](Self::delegate_typed) wrapper.
124    pub fn delegate(
125        mut self,
126        f: impl Fn(&dyn std::any::Any, ItemId) -> Box<dyn Widget> + 'static,
127    ) -> Self {
128        self.delegate = Some(Rc::new(move |payload, id| Some(f(payload, id))));
129        self
130    }
131
132    /// Typed convenience over [`delegate`](Self::delegate): downcasts the
133    /// payload to `P` before calling `f`. A downcast miss debug-asserts and
134    /// skips the item (no widget is materialised) in release.
135    pub fn delegate_typed<P: 'static>(
136        mut self,
137        f: impl Fn(&P, ItemId) -> Box<dyn Widget> + 'static,
138    ) -> Self {
139        self.delegate = Some(Rc::new(move |payload, id| {
140            match payload.downcast_ref::<P>() {
141                Some(typed) => Some(f(typed, id)),
142                None => {
143                    debug_assert!(
144                        false,
145                        "SceneView delegate_typed: payload for {id:?} is not a {}",
146                        std::any::type_name::<P>()
147                    );
148                    None
149                }
150            }
151        }));
152        self
153    }
154
155    /// Replace this view's selection with a (typically shared) one. Pass the
156    /// same [`SceneSelection`](crate::SceneSelection) clone to several views so
157    /// they select together; capture its `selection_signal()` in your delegate
158    /// to highlight selected items reactively (no rebuild). Distinct from the
159    /// [`selection()`](Self::selection) getter; supersedes any
160    /// [`selection_mode`](Self::selection_mode) set earlier.
161    pub fn selection_model(mut self, selection: crate::selection::SceneSelection) -> Self {
162        self.selection = selection;
163        self
164    }
165
166    /// A clone of this view's [`SceneModel`] handle — for handler closures
167    /// that mutate the scene (every mutator is `&self`) or wire additional views.
168    pub fn model(&self) -> SceneModel {
169        self.model.clone()
170    }
171
172    /// Borrow this view's [`SceneModel`] handle.
173    pub fn model_ref(&self) -> &SceneModel {
174        &self.model
175    }
176
177    /// Drain any pending marquee commit synchronously. Normal
178    /// per-frame use never needs this — `place_children` consumes
179    /// the pending commit at the start of every layout pass. Tests
180    /// that drive on_drag without a follow-up layout call this to
181    /// materialise the box-select result.
182    pub fn flush_marquee_commit(&self) -> bool {
183        if let Some((rect, additive)) = self.pending_marquee_commit.take() {
184            self.selection
185                .commit_marquee(&self.model.0.borrow(), rect, additive);
186            self.marquee.set(None);
187            true
188        } else {
189            false
190        }
191    }
192
193    /// Drain any pending drag-to-move commit by translating the
194    /// dragged item's `local_pos` by the queued delta. Descendants
195    /// follow automatically: their `local_pos` is unchanged but
196    /// their `scene_pos` derives from the parent's chain.
197    pub fn flush_pending_item_move(&mut self) -> bool {
198        if let Some((target_id, delta)) = self.pending_item_move.take() {
199            if let Some(local_pos) = self.model.local_pos(target_id) {
200                let new_local_pos = Point::new(local_pos.x + delta.x, local_pos.y + delta.y);
201                self.model.set_local_pos(target_id, new_local_pos);
202            }
203            self.drag_target.set(None);
204            true
205        } else {
206            false
207        }
208    }
209
210    /// Disable user-driven navigation: scroll, pinch, and keyboard
211    /// handlers are not registered, and the SceneView is not made
212    /// focusable. Programmatic [`pan_to`](Self::pan_to) /
213    /// [`zoom_to`](Self::zoom_to) / [`fit_to_content`](Self::fit_to_content)
214    /// still work — this gates only user input.
215    ///
216    /// Use this for **outer** SceneViews in nested chart-style
217    /// patterns: an outer locked SceneView holds axis chrome
218    /// (`TextItem`s reading the inner's pan/zoom signals via
219    /// [`view_transform_signal`](Self::view_transform_signal)),
220    /// an inner interactive SceneView holds the data and accepts
221    /// pan/zoom from the user. Default: interactive (`true`).
222    pub fn interactive(mut self, interactive: bool) -> Self {
223        self.interactive = interactive;
224        self
225    }
226
227    /// Mark this SceneView as logically nested inside another
228    /// SceneView. Affects only the AT walker — the inner
229    /// SceneView reports `Role::Region` instead of the default
230    /// `Role::Pane`, so screen readers don't announce a
231    /// redundant top-level landmark for what's logically a sub-
232    /// region. Pair with [`a11y_label`](Self::a11y_label) to give
233    /// the inner region a useful announce name.
234    ///
235    /// Use case: chart-style nested scenes (outer SceneView holds
236    /// axis chrome, inner SceneView holds data) — the inner one
237    /// should announce as "Data area" or similar, not as another
238    /// "Pane" sibling to the outer.
239    ///
240    /// Default `false`. Apps explicitly set this when they know
241    /// they're nesting; the framework doesn't introspect the
242    /// widget tree to detect nesting automatically (deliberately
243    /// kept declarative — the visual layout doesn't always match
244    /// logical nesting).
245    pub fn nested_a11y(mut self, nested: bool) -> Self {
246        self.a11y_nested = nested;
247        self
248    }
249
250    /// Set the AT label announced as this SceneView's own name.
251    /// Particularly useful for nested SceneViews via
252    /// [`nested_a11y`](Self::nested_a11y), where the inner
253    /// region should have a domain-specific name (e.g. "Chart
254    /// data area"). Default `None` — the SceneView has no
255    /// explicit AT name.
256    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
257        let ls: LocalizedString = label.into();
258        self.a11y_label = Some(ls);
259        self
260    }
261
262    /// Whether the SceneView is currently marked as logically
263    /// nested. Read-only accessor for tests / diagnostics.
264    pub fn is_nested(&self) -> bool {
265        self.a11y_nested
266    }
267
268    /// Coordinate space for `SceneItem` bounds reported to AT.
269    /// Default [`A11yBoundsSpace::Screen`](crate::A11yBoundsSpace::Screen)
270    /// (view-projected, matches the framework's standard widget
271    /// behavior). Switch to
272    /// [`A11yBoundsSpace::Scene`](crate::A11yBoundsSpace::Scene) for
273    /// apps where AT users reason about scene topology rather than
274    /// viewport position (CAD canvases, blueprint editors).
275    pub fn a11y_bounds_space(mut self, space: crate::a11y::A11yBoundsSpace) -> Self {
276        self.a11y_bounds_space = space;
277        self
278    }
279
280    /// Read-only accessor for the configured a11y bounds space.
281    pub fn current_a11y_bounds_space(&self) -> crate::a11y::A11yBoundsSpace {
282        self.a11y_bounds_space
283    }
284
285    /// Configure visual debug overlays. Default: all flags off.
286    /// Pass [`DebugOverlay::ALL`] to enable every overlay or
287    /// construct a custom config:
288    ///
289    /// ```
290    /// # use teksilo_scene::{Scene, SceneView, DebugOverlay};
291    /// # let scene = Scene::new();
292    /// let _view = SceneView::new(scene)
293    ///     .debug_overlay(DebugOverlay {
294    ///         item_bounds: true,
295    ///         viewport: true,
296    ///         ..Default::default()
297    ///     });
298    /// ```
299    ///
300    /// Intended for development only — overlay paint is cheap but
301    /// not free; ship with the default (off) config.
302    pub fn debug_overlay(mut self, overlay: DebugOverlay) -> Self {
303        self.debug_overlay = overlay;
304        self
305    }
306
307    /// Read-only accessor for the active debug overlay config.
308    pub fn current_debug_overlay(&self) -> DebugOverlay {
309        self.debug_overlay
310    }
311
312    /// Install a custom focus-order callback. When set,
313    /// [`next_focus`](Self::next_focus) /
314    /// [`previous_focus`](Self::previous_focus) route through the
315    /// closure instead of falling back to scene insertion order.
316    ///
317    /// Apps wire this to a Tab / Shift+Tab handler in their root
318    /// shortcut/action map. Typical implementations:
319    ///
320    /// - **Graph editor:** walk outgoing-port connections from the
321    ///   current node, return the connected-node `ItemId`.
322    /// - **Corkboard with Acts:** walk a parallel `BTreeMap<ActId,
323    ///   Vec<CardId>>` declared by the app and Tab through cards in
324    ///   story order, not reading order.
325    /// - **Timeline:** sort items by `start_time`, return the next.
326    ///
327    /// The callback receives the full [`Scene`] (read-only), the
328    /// requested [`FocusDirection`], and the currently focused item
329    /// (`None` on the first Tab into the scene). Return `None` to
330    /// signal "no next item" (the framework can then advance focus
331    /// outside the SceneView).
332    ///
333    /// Calling [`next_focus`](Self::next_focus) /
334    /// [`previous_focus`](Self::previous_focus) without a callback
335    /// installed walks scene insertion order — adequate for simple
336    /// scenes; replace as needed.
337    pub fn focus_order<F>(mut self, callback: F) -> Self
338    where
339        F: Fn(&Scene, FocusDirection, Option<ItemId>) -> Option<ItemId> + 'static,
340    {
341        self.focus_order_callback = Some(Rc::new(callback));
342        self
343    }
344
345    /// Compute the next item the focus should advance to in the
346    /// given direction. If a [`focus_order`](Self::focus_order)
347    /// callback is installed, routes through it; otherwise falls
348    /// back to scene insertion order — `Forward` returns the item
349    /// after `current` (or the first if `current` is `None`),
350    /// `Backward` returns the previous (or the last if `current`
351    /// is `None`).
352    pub fn focus_in_direction(
353        &self,
354        direction: FocusDirection,
355        current: Option<ItemId>,
356    ) -> Option<ItemId> {
357        if let Some(cb) = &self.focus_order_callback {
358            return cb(&self.model.0.borrow(), direction, current);
359        }
360        let ids = self.scene().ids();
361        if ids.is_empty() {
362            return None;
363        }
364        match (direction, current) {
365            (FocusDirection::Forward, None) => ids.first().copied(),
366            (FocusDirection::Backward, None) => ids.last().copied(),
367            (FocusDirection::Forward, Some(cur)) => ids
368                .iter()
369                .position(|id| *id == cur)
370                .and_then(|i| ids.get(i + 1).copied()),
371            (FocusDirection::Backward, Some(cur)) => {
372                ids.iter().position(|id| *id == cur).and_then(|i| {
373                    if i == 0 {
374                        None
375                    } else {
376                        ids.get(i - 1).copied()
377                    }
378                })
379            }
380        }
381    }
382
383    /// Convenience: forward-Tab traversal. See
384    /// [`focus_in_direction`](Self::focus_in_direction).
385    pub fn next_focus(&self, current: Option<ItemId>) -> Option<ItemId> {
386        self.focus_in_direction(FocusDirection::Forward, current)
387    }
388
389    /// Convenience: backward-Tab (Shift+Tab) traversal. See
390    /// [`focus_in_direction`](Self::focus_in_direction).
391    pub fn previous_focus(&self, current: Option<ItemId>) -> Option<ItemId> {
392        self.focus_in_direction(FocusDirection::Backward, current)
393    }
394
395    /// Live `Signal<f32>` for the X pan offset. Use this from a
396    /// parent scene (or any reactive consumer) to derive values
397    /// that follow the SceneView's pan — typically axis-label
398    /// text in a chart-style outer SceneView.
399    pub fn pan_x_signal(&self) -> Signal<f32> {
400        self.pan_x.clone()
401    }
402
403    /// Live `Signal<f32>` for the Y pan offset.
404    pub fn pan_y_signal(&self) -> Signal<f32> {
405        self.pan_y.clone()
406    }
407
408    /// Live `Signal<f32>` for the zoom factor.
409    pub fn zoom_signal(&self) -> Signal<f32> {
410        self.zoom.clone()
411    }
412
413    /// Live `Signal<f32>` for the rotation in radians.
414    pub fn rotation_signal(&self) -> Signal<f32> {
415        self.rotation.clone()
416    }
417
418    /// Live `Signal<Transform2D>` for the composed view transform
419    /// (pan + zoom + rotation + bounds-origin). Folds in the
420    /// `bounds.origin` contribution so reactive consumers see the
421    /// exact transform the renderer applies. Updated whenever any
422    /// of the underlying signals change. Use this when the
423    /// consumer needs the full matrix (e.g. converting a screen
424    /// point to scene coords from outside the SceneView).
425    pub fn view_transform_signal(&self) -> Signal<Transform2D> {
426        self.view_transform_signal.clone()
427    }
428
429    /// Override the [`A11yMode`](crate::a11y::A11yMode) for this
430    /// SceneView. Default is `Cooperative` — the visual scene
431    /// layout drives AT emission unless explicitly overridden via
432    /// [`Scene::set_a11y_parent`](crate::Scene::set_a11y_parent).
433    /// Switch to `StrictlyParallel` when your app's AT shape is
434    /// fundamentally different from its visual layout: items
435    /// without a declared logical parent are then suppressed from
436    /// the AT tree, and the app declares every node it wants AT
437    /// users to reach.
438    pub fn a11y_mode(mut self, mode: crate::a11y::A11yMode) -> Self {
439        self.a11y_mode = mode;
440        self
441    }
442
443    /// Override the off-screen visibility policy for the AT walker.
444    /// Default: `ViewportPlusN { n: 1 }` — items inside the
445    /// viewport plus a one-screen margin appear in the AT tree.
446    /// `AllItems` for small scenes where AT users want a complete
447    /// table of contents; `ViewportOnly` for very large scenes where
448    /// listing off-screen content would overwhelm AT clients.
449    pub fn a11y_off_screen_mode(mut self, mode: crate::a11y::A11yOffScreenMode) -> Self {
450        self.a11y_off_screen_mode = mode;
451        self
452    }
453
454    /// Override the size used when the parent doesn't propose one on
455    /// an axis. Defaults to 800×600 logical pixels.
456    pub fn default_size(mut self, w: f32, h: f32) -> Self {
457        self.default_size = Size::new(w, h);
458        if self.last_viewport.get() != self.default_size {
459            self.last_viewport.set(self.default_size);
460        }
461        self
462    }
463
464    /// When set, the view's `layout_response` returns the scene's
465    /// `scene_rect_extent` as its own wanted size — the view sizes
466    /// itself to its scene rather than to `default_size`. Pairs
467    /// naturally with [`Scene::pan_axes`] / [`Scene::zoomable`]
468    /// to embed bounded, non-navigable scenes inline (mini diagrams,
469    /// fixed corkboards). Default `false`.
470    pub fn adopt_scene_size(mut self, on: bool) -> Self {
471        self.adopt_scene_size = on;
472        self
473    }
474
475    /// Configure how left-mouse drag-on-canvas behaves. Default
476    /// [`DragMode::RubberBand`](crate::DragMode) — drag-on-an-item
477    /// moves it (when `IS_DRAGGABLE`), drag-on-empty-space creates
478    /// a marquee. `DragMode::ScrollHandDrag` makes left-drag
479    /// pan the view unconditionally; `DragMode::NoDrag` disables
480    /// the on-drag handler entirely.
481    ///
482    /// Accepts a static `DragMode` — which sets the current value on the
483    /// view's internal signal — or a `Signal<DragMode>` (via
484    /// `impl Into<Prop<DragMode>>`), which **replaces** the internal signal
485    /// with the app-owned one so a toolbar can hold the same handle and
486    /// toggle Hand vs Select vs NoDrag at runtime. To stop sharing, pass a
487    /// fresh `Signal::new(mode)`.
488    pub fn drag_mode(mut self, mode: impl Into<Prop<crate::item_handlers::DragMode>>) -> Self {
489        match mode.into() {
490            Prop::Static(m) => self.drag_mode.set(m),
491            Prop::Bound(sig) => self.drag_mode = sig,
492        }
493        self
494    }
495
496    /// Compose the derived view-transform signal from the four view-state
497    /// signals plus the bounds origin. Coalesced so a simultaneous pan/zoom/
498    /// rotation tick registers a single binding per observing widget (instead
499    /// of five). Called in `new` and re-called by
500    /// [`view_state`](Self::view_state) after the signals are swapped.
501    fn compose_view_transform(
502        pan_x: &Signal<f32>,
503        pan_y: &Signal<f32>,
504        zoom: &Signal<f32>,
505        rotation: &Signal<f32>,
506        bounds_origin: &Signal<Vec2>,
507    ) -> Signal<Transform2D> {
508        pan_x
509            .zip3(pan_y, zoom)
510            .zip(rotation)
511            .zip(bounds_origin)
512            .map_coalesced(|(((px, py, z), r), bo)| {
513                compose_view(Vec2::new(*px + bo.x, *py + bo.y), *z, *r)
514            })
515    }
516
517    /// Replace the view's pan / zoom / rotation signals with app-owned ones.
518    ///
519    /// The four view-state signals become the app's to hold, share, and
520    /// persist — so view state survives a *rebuild-from-state* (a wrapper that
521    /// reconstructs the `Scene` + `SceneView` keeps the same signals and the
522    /// viewport doesn't jump back to the origin), a "Reset View" button can
523    /// snap them, and two views could share one camera. The derived
524    /// [`view_transform_signal`](Self::view_transform_signal) is recomposed
525    /// from the injected signals.
526    ///
527    /// Must be called before the view is added to the tree (like the other
528    /// builder methods) — `build()` reads `view_transform_signal` once.
529    pub fn view_state(
530        mut self,
531        pan_x: Signal<f32>,
532        pan_y: Signal<f32>,
533        zoom: Signal<f32>,
534        rotation: Signal<f32>,
535    ) -> Self {
536        // Recompose first (borrows the new signals), then move them into self.
537        self.view_transform_signal = Self::compose_view_transform(
538            &pan_x,
539            &pan_y,
540            &zoom,
541            &rotation,
542            &self.bounds_origin_signal,
543        );
544        self.pan_x = pan_x;
545        self.pan_y = pan_y;
546        self.zoom = zoom;
547        self.rotation = rotation;
548        self
549    }
550
551    /// Seed the initial pan offset (logical pixels). The view keeps ownership
552    /// of the signals; for app-owned signals use [`view_state`](Self::view_state).
553    pub fn initial_pan(self, x: f32, y: f32) -> Self {
554        self.pan_x.set(x);
555        self.pan_y.set(y);
556        self
557    }
558
559    /// Seed the initial zoom factor (clamped to the active zoom range).
560    pub fn initial_zoom(self, zoom: f32) -> Self {
561        let gated = self.gate_zoom_target(zoom);
562        self.zoom.set(gated);
563        self
564    }
565
566    /// Seed the initial rotation (radians).
567    pub fn initial_rotation(self, radians: f32) -> Self {
568        self.rotation.set(radians);
569        self
570    }
571
572    /// Reactive accessor for the drag mode. Useful for toolbars
573    /// that need to read the current mode (e.g. to highlight the
574    /// active tool button) and write to it.
575    pub fn drag_mode_signal(&self) -> Signal<crate::item_handlers::DragMode> {
576        self.drag_mode.clone()
577    }
578
579    /// Install a closure painted **before** the items walk. The
580    /// canvas already has the view-transform scope pushed, so the
581    /// closure paints in scene coords. The `Rect` argument is the
582    /// scene-coord visible region — useful for tiled backgrounds
583    /// (graph-paper grids, ruled lines, dot grids) so the closure
584    /// only emits geometry the user can actually see.
585    ///
586    /// ```ignore
587    /// SceneView::new(scene).background(|canvas, _ctx, region| {
588    ///     // Draw a 50-unit grid covering only the visible region.
589    ///     let step = 50.0;
590    ///     let x0 = (region.x / step).floor() * step;
591    ///     let mut x = x0;
592    ///     while x < region.x + region.width {
593    ///         canvas.draw_line(/* ... */);
594    ///         x += step;
595    ///     }
596    /// })
597    /// ```
598    pub fn background<F>(mut self, paint: F) -> Self
599    where
600        F: Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect) + 'static,
601    {
602        self.background_paint = Some(Rc::new(paint));
603        self
604    }
605
606    /// Install a closure painted **after** the items walk and the
607    /// marquee, but before any debug overlay. Same coordinate
608    /// conventions as [`background`](Self::background). Used for
609    /// scene-coord chrome that should ride over content (rulers,
610    /// snap-line indicators, drop hints).
611    pub fn foreground<F>(mut self, paint: F) -> Self
612    where
613        F: Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect) + 'static,
614    {
615        self.foreground_paint = Some(Rc::new(paint));
616        self
617    }
618
619    /// Enable magnetism on this view with the given
620    /// [`MagnetismConfig`].
621    ///
622    /// Once installed, this view's lightweight item drags snap their
623    /// magnets onto compatible magnets on other items, magnet handles
624    /// become grabbable for port-drag wires, the keyboard connect flow
625    /// (the config's connect key) is available while the view is
626    /// focused, magnet markers paint, and each enabled magnet is
627    /// emitted as a synthetic AT node. A view with no magnetism config
628    /// ignores magnets entirely.
629    pub fn magnetism(mut self, config: crate::magnet::MagnetismConfig) -> Self {
630        self.magnetism = Some(Rc::new(config));
631        self
632    }
633
634    /// The reactive enabled signal of the installed magnetism config, if
635    /// any — for a toolbar to read or bind a magnetism on/off toggle.
636    pub fn magnetism_enabled_signal(&self) -> Option<Signal<bool>> {
637        self.magnetism.as_ref().map(|c| c.enabled_signal())
638    }
639
640    /// Drop the cached paint output for `id`. Apps that mutate
641    /// item-internal state without going through a [`Scene`] mutator
642    /// (e.g. a custom item whose paint depends on a private
643    /// `Signal<Color>` that doesn't drive `local_bounds`) call this
644    /// to invalidate. The cache is otherwise dropped automatically
645    /// on `LocalBoundsChanged` / `OpacityChanged` / `Removed`.
646    pub fn invalidate_item_cache(&self, id: ItemId) {
647        self.item_cache.borrow_mut().evict(id);
648    }
649
650    /// Number of cached entries currently held. Diagnostic / test
651    /// hook — apps shouldn't normally need this.
652    pub fn item_cache_len(&self) -> usize {
653        self.item_cache.borrow().len()
654    }
655
656    /// Minimum zoom factor (default 0.1×). Applied as a clamp to all
657    /// programmatic and gesture-driven zoom changes via the
658    /// view-level [`zoom_range_override`](Self::zoom_range_override).
659    /// Shim — updates the lower bound of the override range. The
660    /// effective clamp is the intersection of Scene-level
661    /// [`Scene::set_zoom_range`](crate::Scene::set_zoom_range) and
662    /// this override (tightening-only — neither side can loosen).
663    pub fn min_zoom(self, v: f32) -> Self {
664        let lo = v.max(0.0001);
665        let current = self.zoom_range_override.get();
666        let hi = current
667            .as_ref()
668            .map(|r| *r.end())
669            .unwrap_or(DEFAULT_MAX_ZOOM);
670        self.zoom_range_override.set(Some(lo..=hi.max(lo)));
671        self
672    }
673
674    /// Maximum zoom factor (default 10×). Shim — updates the upper
675    /// bound of the override range. See [`min_zoom`](Self::min_zoom).
676    pub fn max_zoom(self, v: f32) -> Self {
677        let current = self.zoom_range_override.get();
678        let lo = current
679            .as_ref()
680            .map(|r| *r.start())
681            .unwrap_or(DEFAULT_MIN_ZOOM);
682        self.zoom_range_override.set(Some(lo..=v.max(lo)));
683        self
684    }
685
686    /// Replace the view-level zoom-range override wholesale.
687    /// `None` clears the override so this view imposes no zoom
688    /// clamp of its own (Scene-level constraints still apply).
689    /// Tightening rule: the effective clamp is the intersection
690    /// with `Scene::current_zoom_range()` — neither can loosen.
691    pub fn zoom_range_override(self, range: Option<std::ops::RangeInclusive<f32>>) -> Self {
692        self.zoom_range_override.set(range);
693        self
694    }
695
696    /// Reactive accessor for the view-level zoom-range override.
697    /// Use this to mutate the override at runtime (e.g. from a
698    /// toolbar). Mutations take effect on the next gesture.
699    pub fn zoom_range_override_signal(&self) -> Signal<Option<std::ops::RangeInclusive<f32>>> {
700        self.zoom_range_override.clone()
701    }
702
703    /// View-level *tightening* override on pan bounds, in scene
704    /// coords. The effective clamp at gesture-time is the rect
705    /// intersection with `Scene::current_pan_bounds()` — view
706    /// overrides cannot loosen what the `Scene` declares. `None`
707    /// (default) means no view-side clamp.
708    pub fn pan_bounds_override(self, bounds: Option<Rect>) -> Self {
709        self.pan_bounds_override.set(bounds);
710        self
711    }
712
713    /// Reactive accessor for the view-level pan-bounds override.
714    /// Use this to mutate the override at runtime (e.g. dynamically
715    /// shrinking the navigable area). Mutations take effect on the
716    /// next gesture.
717    pub fn pan_bounds_override_signal(&self) -> Signal<Option<Rect>> {
718        self.pan_bounds_override.clone()
719    }
720
721    /// Logical pixels of pan applied per scroll-wheel line notch.
722    /// Defaults to 16 px (matches `ScrollArea`).
723    pub fn line_height(mut self, px: f32) -> Self {
724        self.line_height = px.max(0.0);
725        self
726    }
727
728    /// Whether a wheel the scene can't absorb (already clamped at its
729    /// `pan_bounds`) chains to an ancestor scrollable
730    /// ([`OverscrollBehavior::Chain`], the default — matches the widget
731    /// scrollables) or is contained ([`OverscrollBehavior::Contain`]). Use
732    /// `Contain` for a tightly-bounded scene embedded in a scroll view that
733    /// should never steal the scene's wheel.
734    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
735        self.overscroll_behavior = behavior;
736        self
737    }
738
739    /// Wrap this view in a [`SceneScrollView`](crate::SceneScrollView), adding
740    /// draggable scroll bars with the widget-tier `ScrollArea`'s options (mode,
741    /// per-axis policy, thickness). The bars track the camera and drive panning;
742    /// native wheel / drag panning — and its smoothing — keeps working.
743    ///
744    /// Configure the result with the `SceneScrollView` builder methods:
745    ///
746    /// ```no_run
747    /// # use teksilo_scene::{Scene, SceneView, ScrollBarMode, ScrollBarPolicy};
748    /// let scrollable = SceneView::new(Scene::new())
749    ///     .with_scroll_bars()
750    ///     .scroll_bar_mode(ScrollBarMode::Overlay)
751    ///     .vertical_policy(ScrollBarPolicy::AsNeeded);
752    /// # let _ = scrollable;
753    /// ```
754    pub fn with_scroll_bars(self) -> crate::SceneScrollView {
755        crate::SceneScrollView::new(self)
756    }
757}