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 ®ion,
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}