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}