teksilo_scene/view.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SceneView`] — the viewport widget that hosts a [`Scene`] and
5//! places its items at scene coordinates.
6//!
7//! `SceneView` is the bridge between the model layer ([`Scene`] /
8//! [`SceneModel`]) and the render/event pipeline. It
9//! manages a pan/zoom/rotation camera, materialises heavyweight widgets
10//! for delegated items, dispatches pointer events to lightweight item
11//! handlers, and feeds synthetic AT nodes to AccessKit for every visible
12//! lightweight item. Multiple `SceneView`s can share one `SceneModel` and
13//! reconcile independently on every mutation.
14//!
15//! ## Composition
16//!
17//! - **Placement.** `place_children` plants each materialised
18//! heavyweight widget at its scene-space rect (composed from the
19//! item's `local_pos`, `transform`, and parent chain).
20//! - **Paint bands.** Three passes: `paint` draws the `Under` lightweight
21//! items (backdrop), the arena child-walk draws the heavyweight widgets
22//! — with an `Interleaved` item's own paint node slotted in among them by
23//! `z` — then `post_paint` draws the `Over` lightweight items + marquee /
24//! foreground / debug overlays. `z` orders within each tier; the band
25//! ([`Scene::set_layer`](crate::Scene::set_layer)) chooses `Under`,
26//! `Interleaved` or `Over`. See `docs/teksilo-scene.md` §"Z-order and paint bands".
27//! - **View transform.** Pan / zoom / rotation are four animated
28//! `Signal<f32>`s on `SceneView`, composed into a derived
29//! `Signal<Transform2D>` bound via `BuildContext::set_content_transform`
30//! on the view itself. The render walker pushes that scope around
31//! the entire subtree, so every materialised widget is visually
32//! transformed; transform-aware hit-test routes pointer events
33//! through the same scope.
34//! - **Spatial index.** `place_children` and the paint walk consult
35//! `Scene::items_in_rect(visible_region)` to skip off-screen items.
36//! - **Idle gating.** Pan / zoom that's reached its terminal tick
37//! stops scheduling frames via the engine's per-node `paint_epoch`.
38//!
39//! ## Input wiring
40//!
41//! - **`on_scroll`** — trackpad two-finger pan (`ScrollDelta::Pixels`)
42//! and mouse wheel (`ScrollDelta::Lines`) animate the pan signals
43//! via `Easing::EaseOut`. Trackpad momentum events from winit
44//! arrive as further `Pixels` deltas; the existing animation
45//! pipeline turns this into smooth inertial fling without a custom
46//! recognizer.
47//! - **`on_pinch`** — a pinch (`PinchPhase::Changed`) feeds `scale` into
48//! the zoom signal and `rotation` into the rotation signal, anchored
49//! around the gesture center so the scene point under the user's
50//! fingers stays put. Two producers reach the one handler: the OS
51//! trackpad stream and the two-contact touch recognizer, and both
52//! satisfy the contract on
53//! [`GestureEvent::PinchChanged`](teksilo_core::gesture::GestureEvent::PinchChanged)
54//! — `scale` is the factor **since the previous sample** (folded in with
55//! `zoom *= scale`) and `rotation` the **radian** delta since the previous
56//! sample (`rotation += delta`). Degrees never reach here: winit's unit is
57//! converted at the platform seam. `view::tests::touch_camera` holds both
58//! halves.
59//! - **Reduced-motion** — at build time, captures
60//! [`BuildContext::prefers_reduced_motion`](teksilo_core::build_context::BuildContext::prefers_reduced_motion).
61//! When set, scroll handlers `set` the signals directly instead of
62//! `animate_to`-ing them; pinch is already instantaneous.
63//! - **Drag-to-move** for items carrying `IS_DRAGGABLE`; **marquee**
64//! selection on the empty viewport surface (or under
65//! [`DragMode::ScrollHandDrag`](crate::DragMode), pan-on-drag).
66//! - **Pan to scroll** — an interactive view declares a
67//! [`PanClaim`](teksilo_core::pointer::touch_action::PanClaim), so a finger's
68//! pan reaches the same `on_scroll` handler as a
69//! `ScrollSource::TouchPan` sample and moves the camera with no tween (a
70//! finger is already the animation), hard-clamped on the coast that follows
71//! the lift, and declined at a bound so the gesture chains to whatever
72//! scrolls outside the view. Deliberately **not** routed through
73//! `teksilo_widgets::common::scrollable`: that models a surface as an offset
74//! in `[0, max]`, and a scene's pan is negated, bounded by a zoom-dependent
75//! rectangle, and centre-pinned where that rectangle is smaller than the
76//! viewport. With selection or magnetism on, the view's own drag recognizer
77//! is the competitor a finger meets first and it wins at the smaller drag
78//! slop — the marquee, not the camera.
79//!
80//! ## Example
81//!
82//! ```rust
83//! # use teksilo_scene::{Scene, SceneModel, SceneView, SceneSelectionMode, RectItem};
84//! # use teksilo_canvas::{Point, Rect};
85//! # use teksilo_tokens::Color;
86//! // Build a shared model and add a lightweight rect item.
87//! let model = SceneModel::new();
88//! let local_bounds = Rect::new(0.0, 0.0, 120.0, 80.0);
89//! let item_id = model.add_item(
90//! RectItem::new(local_bounds).fill(Color::from_rgb(0.2, 0.5, 0.8)),
91//! Point::new(50.0, 50.0), // local_pos in scene coords
92//! );
93//!
94//! // Create viewports backed by that model; each has its own camera.
95//! let _view_a = SceneView::with_model(model.clone())
96//! .selection_mode(SceneSelectionMode::Single)
97//! .default_size(800.0, 600.0)
98//! .initial_zoom(1.5);
99//!
100//! let _view_b = SceneView::with_model(model.clone())
101//! .interactive(false); // axis-chrome / overview pane
102//!
103//! // Both views see the item; the model remembers its local_pos.
104//! assert!(model.local_pos(item_id).is_some());
105//! ```
106
107use std::cell::{Cell, RefCell};
108use std::collections::{HashMap, HashSet};
109use std::rc::Rc;
110use std::time::Duration;
111
112use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D, Vec2};
113use teksilo_core::binding::BindingLevel;
114use teksilo_core::build_context::BuildContext;
115use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
116use teksilo_core::gesture::PinchPhase;
117use teksilo_core::overscroll::OverscrollBehavior;
118use teksilo_core::signal::Signal;
119use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
120use teksilo_core::widget_builder::HandlerSet;
121use teksilo_core::widget_id::WidgetId;
122use teksilo_tokens::Easing;
123
124use crate::item::ItemId;
125use crate::magnet::{MagnetId, MagnetSnap, MagnetismConfig};
126use crate::pick::{PaintKey, RANK_OVER};
127use crate::scene::Scene;
128use crate::scene_model::SceneModel;
129use crate::shape::{ItemSelectionMode, ItemShape, SceneRegion};
130use crate::transform::{anchor_pan_for_pinch, compose_view};
131use teksilo_i18n::LocalizedString;
132
133/// Logical pixels of pan applied per `ScrollDelta::Lines` notch.
134/// The same convention `ScrollArea` uses for its own `line_height` in
135/// teksilo-widgets (which defaults to 20).
136const DEFAULT_LINE_HEIGHT: f32 = 16.0;
137const DEFAULT_PAN_DURATION: Duration = Duration::from_millis(120);
138const DEFAULT_ZOOM_DURATION: Duration = Duration::from_millis(180);
139const DEFAULT_MIN_ZOOM: f32 = 0.1;
140const DEFAULT_MAX_ZOOM: f32 = 10.0;
141
142/// Default [`SceneView::retention_margin`], in screen pixels.
143///
144/// 96 px is what a very fast fling covers in one frame at 120 Hz
145/// (11 520 px/s), which is the quantity the margin has to beat: it exists so a
146/// card is woken while it is still off screen rather than on the frame it
147/// becomes visible.
148pub const DEFAULT_RETENTION_MARGIN: f32 = 96.0;
149
150/// Maximum movement, in **view pixels**, between PointerDown and PointerUp for
151/// the gesture to count as a tap rather than a drag — the floor a precise
152/// pointer gets.
153///
154/// Measured on screen rather than in scene units because the same hand movement
155/// has to mean the same thing at every zoom. A scene-unit comparison divides
156/// the tolerance by the zoom, so the tolerance shrinks as the view zooms out
157/// and grows as it zooms in: far enough out, an ordinary click carries more
158/// jitter than the tolerance allows and no item can be tapped at all; far
159/// enough in, a deliberate small drag is reported as a tap. See
160/// [`tap_tolerance_px`].
161const TAP_MOVEMENT_THRESHOLD: f32 = 4.0;
162
163/// The tap-versus-drag movement tolerance for `kind`, in view pixels.
164///
165/// A precise pointer keeps the view's own floor. A coarse one is allowed the
166/// travel its gesture profile already calls a tap: a contact patch rolls as it
167/// lifts, and the profile is where the framework states how much of that is
168/// still one tap. Reads a build-time [`InputTokens`](teksilo_tokens::InputTokens)
169/// snapshot, which cannot go
170/// stale — a density change marks the tree for rebuild.
171fn tap_tolerance_px(
172 tokens: &teksilo_tokens::InputTokens,
173 kind: teksilo_tokens::PointerKind,
174) -> f32 {
175 if kind.is_coarse() {
176 TAP_MOVEMENT_THRESHOLD.max(tokens.profile(kind).tap_slop)
177 } else {
178 TAP_MOVEMENT_THRESHOLD
179 }
180}
181
182/// How far a *missed* grab may be re-attributed to scene content of
183/// `screen_size`, in view pixels.
184///
185/// Delegates to [`HitSlop::for_pointer`](teksilo_core::pointer::hit_slop::HitSlop::for_pointer),
186/// which is the framework's one answer to this question, so the scene inherits
187/// its two properties rather than restating them: the offer is zero for a mouse
188/// at every density (the mouse profile's radius is `0.0`, and a pen's is small),
189/// and it is zero for content already at least the density's target size on its
190/// smaller axis. A
191/// scene's content is mostly large, so in practice this widens exactly the
192/// small items — a connector stroke, a port dot, a pin — that a finger cannot
193/// otherwise land on.
194fn grab_slop_px(
195 tokens: &teksilo_tokens::InputTokens,
196 kind: teksilo_tokens::PointerKind,
197 screen_size: Size,
198) -> f32 {
199 teksilo_core::pointer::hit_slop::HitSlop::for_pointer(kind, tokens).outset_for(screen_size)
200}
201
202/// Distance from `p` to the nearest point of `r`; `0.0` when `p` is inside.
203///
204/// The slop pass settles overlapping candidates by distance rather than by
205/// z-order, so a stroke 3 px away wins over a box whose *inflated* rectangle
206/// the press also fell inside 8 px away.
207fn distance_to_rect(p: Point, r: Rect) -> f32 {
208 let dx = (r.x - p.x).max(p.x - (r.x + r.width)).max(0.0);
209 let dy = (r.y - p.y).max(p.y - (r.y + r.height)).max(0.0);
210 dx.hypot(dy)
211}
212/// Take the tightening intersection of two optional zoom ranges:
213/// `(max(lo), min(hi))`. `None` on either side leaves the other
214/// untouched; `None` on both returns `None`. Used to compose
215/// Scene-level + view-level constraints — neither side can loosen.
216fn intersect_zoom_range(
217 a: Option<&std::ops::RangeInclusive<f32>>,
218 b: Option<&std::ops::RangeInclusive<f32>>,
219) -> Option<std::ops::RangeInclusive<f32>> {
220 match (a, b) {
221 (None, None) => None,
222 (Some(r), None) | (None, Some(r)) => Some(r.clone()),
223 (Some(a), Some(b)) => {
224 let lo = a.start().max(*b.start());
225 let hi = a.end().min(*b.end());
226 // Guard against degenerate intersect: if the ranges
227 // don't overlap (lo > hi), collapse to the tighter
228 // side's lo so callers see a single allowed value
229 // rather than NaN-clamping.
230 Some(lo..=hi.max(lo))
231 }
232 }
233}
234
235/// Clamp a zoom factor through an optional range. `None` is the
236/// identity — no clamp applied.
237fn clamp_zoom(z: f32, range: Option<&std::ops::RangeInclusive<f32>>) -> f32 {
238 match range {
239 None => z,
240 Some(r) => z.clamp(*r.start(), *r.end()),
241 }
242}
243
244/// Take the tightening intersection of two optional pan-bounds
245/// rects. `None` on either side leaves the other untouched; `None`
246/// on both returns `None`. If both are `Some` and the rect
247/// intersection is empty (no overlap), falls back to the first
248/// (Scene-declared) bounds — the more authoritative side.
249fn intersect_pan_bounds(scene: Option<Rect>, view: Option<Rect>) -> Option<Rect> {
250 match (scene, view) {
251 (None, None) => None,
252 (Some(r), None) | (None, Some(r)) => Some(r),
253 (Some(a), Some(b)) => {
254 let x = a.x.max(b.x);
255 let y = a.y.max(b.y);
256 let right = a.right().min(b.right());
257 let bottom = a.bottom().min(b.bottom());
258 if right > x && bottom > y {
259 Some(Rect::new(x, y, right - x, bottom - y))
260 } else {
261 Some(a)
262 }
263 }
264 }
265}
266
267/// Clamp a pan vector against `bounds` so the visible scene region
268/// (derived from `viewport` and `zoom`) stays inside the bounds rect.
269/// When the rect is smaller than the visible viewport on an axis,
270/// that axis is centered on the bounds rather than clamped.
271///
272/// `bounds` is in scene coords; `viewport` is the SceneView's
273/// resolved size in screen pixels; `zoom` is the current zoom
274/// factor. Returns `pan` unchanged when `bounds` is `None`.
275fn clamp_pan_to_bounds(pan: Vec2, bounds: Option<&Rect>, viewport: Size, zoom: f32) -> Vec2 {
276 let Some(b) = bounds else { return pan };
277 if zoom <= 0.0 || viewport.width <= 0.0 || viewport.height <= 0.0 {
278 return pan;
279 }
280 // visible_scene_x = [-pan.x / zoom, (viewport_w - pan.x) / zoom]
281 // For visible to lie inside [b.x, b.right]:
282 // pan.x in [viewport_w - b.right * zoom, -b.x * zoom]
283 let clamp_axis = |pan_c: f32, b_lo: f32, b_hi: f32, vp: f32| {
284 let lo = vp - b_hi * zoom;
285 let hi = -b_lo * zoom;
286 if hi >= lo {
287 pan_c.clamp(lo, hi)
288 } else {
289 // Bounds smaller than viewport on this axis — center.
290 // visible_center = b_lo + (b_hi - b_lo)/2
291 // = (b_lo + b_hi) / 2
292 // = (vp/2 - pan_c) / zoom
293 // → pan_c = vp/2 - (b_lo + b_hi)/2 * zoom
294 vp / 2.0 - ((b_lo + b_hi) / 2.0) * zoom
295 }
296 };
297 Vec2::new(
298 clamp_axis(pan.x, b.x, b.right(), viewport.width),
299 clamp_axis(pan.y, b.y, b.bottom(), viewport.height),
300 )
301}
302
303/// One entry's measurement history inside one view. See
304/// [`SceneView::measure_state`].
305///
306/// # What an oscillation is, and what an edit is
307///
308/// A measured size feeds back into the model and the model feeds the next
309/// pass, so a body that cannot answer the same question twice would oscillate
310/// for ever. Bounding that needs something that can tell an oscillation from
311/// an ordinary edit — and the two are **indistinguishable from the numbers**.
312/// `100 → 120 → 100` is an oscillation when the body is answering its own last
313/// answer, and is a user typing a character that wraps and then deleting it
314/// when it is not; a user who types and deletes the same character twice hands
315/// a value-pattern detector a sequence bit-identical to a flip-flop's. Freezing
316/// the second case leaves a card at a height its content abandoned, and — since
317/// a card that writes nothing is never asked again — leaves it there for ever.
318///
319/// What separates them is not the values but **what drove the pass**. A body's
320/// answer is a function of its content and of the width it is offered. The
321/// width is held fixed here (a change to it retires the history outright), and
322/// the content cannot change without something dirtying the card: a `Signal`
323/// bound at `Relayout`, a rebuild, a theme swap, a fresh node. None of that
324/// fires an [`ItemChange`](crate::ItemChange) the view could observe, but all of
325/// it sets `needs_layout` on the card's own node — a relayout-level binding
326/// marks its whole ancestor chain — and the arena clears those flags only
327/// **after** the walk, so when `place_children` asks, the answer is still
328/// there. `layout_impl::card_was_reached` is the one lookup that asks it.
329///
330/// This view's own write, by contrast, reaches nothing below it: it emits
331/// [`MeasuredSizeChanged`](crate::ItemChange::MeasuredSizeChanged), the
332/// observer bumps `measure_dirty`, and that signal is bound at `Relayout` on
333/// the `SceneView` node — whose ancestors are marked, and whose descendants are
334/// not. So on the pass a write causes, the card reads **clean**, and a clean
335/// card that answers differently is reading back what was written to it. That
336/// is not a guess about a pattern; it is the definition of the non-idempotence
337/// [`SizePolicy`](crate::SizePolicy) warns about.
338///
339/// # What is bounded, and by what
340///
341/// Nothing is pinned to a value. A contradiction on a clean pass resolves to
342/// the **larger** of the two answers (a box that is too big shows everything;
343/// one that is too small cuts content off) and is counted as a strike. At
344/// [`MAX_CONTRADICTIONS`] the entry stops taking new answers and re-states the
345/// one the model already holds — which writes nothing, and silence is what ends
346/// the chain, because a pass that writes nothing schedules no successor. So a
347/// hopeless body costs a bounded number of passes **per external event**
348/// instead of an unbounded number for ever, and an edited body is never bounded
349/// at all: every one of its passes is externally driven, and an externally
350/// driven pass always takes the answer it is given.
351#[derive(Debug, Clone, Copy)]
352pub(crate) struct MeasureTrack {
353 /// The width the last measurement was taken at. A width change retires the
354 /// history — that is a different question, and its answer owes nothing to
355 /// the previous one.
356 width: f32,
357 /// The size resolved last, which is also the size the model was told.
358 last: Size,
359 /// How many times this entry has contradicted itself, on passes nothing
360 /// external drove, since the question it is being asked last changed. The
361 /// two things that change the question are the two things that reset it: a
362 /// different width, and a pass something outside this view drove.
363 strikes: u8,
364}
365
366/// How far apart two measurements have to be to count as different.
367///
368/// Half a logical pixel: below what a reader can see, above the float noise a
369/// shaper produces for the same text measured twice. Without it a body whose
370/// height differs in the last bit writes on every pass, and every write is a
371/// relayout.
372const MEASURE_EPSILON: f32 = 0.5;
373
374/// How many self-contradictions at one width an entry is allowed before this
375/// view stops taking its answers for the rest of the chain.
376///
377/// Two, because the two strikes do different jobs and both are reachable. The
378/// first resolves a flip-flop: taking the larger of the two answers lands on a
379/// size the body itself named, and for a body that alternates between two
380/// values that is already the fixed point — it agrees on the next pass and the
381/// chain ends. The second catches the body the first cannot: one whose answer
382/// *grows* with what it is given, for which "take the larger" is a ratchet, so
383/// the only way to stop is to stop asking.
384const MAX_CONTRADICTIONS: u8 = 2;
385
386impl MeasureTrack {
387 /// A fresh history whose first answer is `candidate`, taken at `width`.
388 fn new(width: f32, candidate: Size) -> Self {
389 MeasureTrack {
390 width,
391 last: candidate,
392 strikes: 0,
393 }
394 }
395
396 /// What this pass should place the card at, given a fresh measurement.
397 ///
398 /// `question_changed` answers "did anything but this view's own write reach
399 /// the card since it was last measured" — see the type's documentation for
400 /// why that is the whole discriminator. It is a closure because the two
401 /// branches that decide without it (the width changed; the answer did not)
402 /// are the ones taken on every ordinary pass, and neither should pay for an
403 /// arena lookup.
404 fn resolve(
405 &mut self,
406 width: f32,
407 candidate: Size,
408 question_changed: impl FnOnce() -> bool,
409 ) -> Size {
410 if (self.width - width).abs() > MEASURE_EPSILON {
411 *self = MeasureTrack::new(width, candidate);
412 return candidate;
413 }
414 if size_close(self.last, candidate) {
415 return self.last;
416 }
417 if question_changed() {
418 // A different question, so a different answer is not a
419 // contradiction — it is the point.
420 self.strikes = 0;
421 self.last = candidate;
422 return candidate;
423 }
424 self.strikes = self.strikes.saturating_add(1);
425 if self.strikes >= MAX_CONTRADICTIONS {
426 // Re-state what the model already holds. The caller writes nothing,
427 // nothing schedules another pass, and the chain ends here.
428 return self.last;
429 }
430 let settled = Size::new(
431 self.last.width.max(candidate.width),
432 self.last.height.max(candidate.height),
433 );
434 self.last = settled;
435 settled
436 }
437}
438
439/// Two sizes that no reader could tell apart. See [`MEASURE_EPSILON`].
440fn size_close(a: Size, b: Size) -> bool {
441 (a.width - b.width).abs() <= MEASURE_EPSILON && (a.height - b.height).abs() <= MEASURE_EPSILON
442}
443
444/// The single chokepoint for the pan-bounds clamp: take the tightening
445/// intersection of the Scene-declared and view-override bounds, then
446/// clamp `candidate` against it for the given `viewport` and `zoom`.
447///
448/// Every pan path (the camera API plus all five gesture handlers) ends
449/// in this exact `intersect → clamp` pair. Funnelling them through one
450/// function keeps a new pan path from silently skipping the intersection
451/// or the clamp. Each site still computes its own `candidate` (the parts
452/// that legitimately differ — `z_new` vs committed zoom for the
453/// zoom-coupled sites, `animation_target` vs live pan for the tween-
454/// chaining sites — stay at the call site).
455fn clamp_pan(
456 candidate: Vec2,
457 scene_bounds: Option<Rect>,
458 view_bounds: Option<Rect>,
459 viewport: Size,
460 zoom: f32,
461) -> Vec2 {
462 let effective = intersect_pan_bounds(scene_bounds, view_bounds);
463 clamp_pan_to_bounds(candidate, effective.as_ref(), viewport, zoom)
464}
465
466/// Apply a [`PanAxes`](crate::scene::PanAxes) policy to a candidate pan:
467/// the permitted axis takes `candidate`, the restricted axis is held at
468/// `hold` (the gesture's reference pan). `Both` passes through; `None`
469/// holds both.
470///
471/// Shared by the pinch and hand-drag sites, which hold the orthogonal
472/// axis at the pan captured when the gesture began, and by the camera's
473/// `gate_pan_target`, which holds it at the current pan. The wheel and
474/// keyboard sites instead zero their input *deltas* (so an excluded axis
475/// passes through to ancestor scrollables / arrow-key guards) and do not
476/// call this.
477fn apply_pan_axes(candidate: Vec2, hold: Vec2, axes: crate::scene::PanAxes) -> Vec2 {
478 use crate::scene::PanAxes;
479 match axes {
480 PanAxes::Both => candidate,
481 PanAxes::None => hold,
482 PanAxes::Horizontal => Vec2::new(candidate.x, hold.y),
483 PanAxes::Vertical => Vec2::new(hold.x, candidate.y),
484 }
485}
486
487/// In-flight marquee box-select state. Tracked in scene
488/// coordinates so pan/zoom mid-drag (e.g. the user holds shift
489/// and scrolls while dragging) doesn't break the rectangle's
490/// alignment with scene contents.
491#[derive(Debug, Clone, Copy)]
492pub(super) struct MarqueeState {
493 origin: Point,
494 current: Point,
495 /// Whether the marquee is additive (Ctrl/Shift held at start).
496 /// On commit, additive = `extend`; non-additive = `replace`.
497 additive: bool,
498}
499
500impl MarqueeState {
501 fn rect(self) -> Rect {
502 let x = self.origin.x.min(self.current.x);
503 let y = self.origin.y.min(self.current.y);
504 let w = (self.origin.x - self.current.x).abs();
505 let h = (self.origin.y - self.current.y).abs();
506 Rect::new(x, y, w, h)
507 }
508}
509
510/// A drag-to-move in flight: which lightweight item is being
511/// translated, in scene coords. The committed delta on `Ended`
512/// is `current_scene - anchor_scene`; that delta is applied to
513/// every item in [`SceneView::drag_group`], and a declared
514/// descendant follows its ancestor because its `local_pos` is
515/// relative to it.
516#[derive(Debug, Clone, Copy)]
517pub(super) struct DragTarget {
518 item_id: ItemId,
519 /// Scene-coord position where the drag started.
520 anchor_scene: Point,
521 /// Current scene-coord position (updated on each Moved /
522 /// Ended). Allows paint to render the in-flight offset for
523 /// live visual feedback.
524 ///
525 /// **A pointer position, not an item position.** Both anchors are the press
526 /// point, and the pair is only ever read as a difference — the grab offset
527 /// between the cursor and the item's own corner lives in neither of them.
528 /// That is exactly why the geometry constraint is stated over
529 /// [`start_frame`](Self::start_frame) instead: snapping a cursor to a grid
530 /// leaves the item off-grid by the grab offset, for ever.
531 current_scene: Point,
532 /// The scene-space box the dragged group occupied when the drag started —
533 /// [`Scene::transform_frame`](crate::Scene::transform_frame) over the whole
534 /// `drag_group`, captured once at the press and fixed for the gesture.
535 ///
536 /// The `start` a geometry constraint measures against, and the quantity
537 /// that makes a grid snap land the *item* on the grid.
538 ///
539 /// `None` when the scene declares **no** geometry constraint — capturing it
540 /// costs a full prune of the selection and nothing else reads it, so a
541 /// scene with no rule pays nothing for the mechanism, at the press as well
542 /// as on every sample — or when the group resolved to no geometry at all,
543 /// which, since the hit test had just found the grabbed item, means it was
544 /// removed between the two. Either way no constraint runs for this drag.
545 start_frame: Option<crate::transform_session::TransformFrame>,
546}
547
548/// The items a pointer drag of `grabbed` carries — see
549/// [`SceneView::drag_group`], which is this function with the view's own
550/// handles.
551///
552/// A free function because the drag closure owns clones rather than a `&self`,
553/// and the closure's answer must be the *same* answer the commit and the paint
554/// feedback get: a second implementation there would be a second definition of
555/// "what is moving".
556pub(super) fn drag_group_of(
557 model: &SceneModel,
558 selection: &crate::selection::SceneSelection,
559 grabbed: ItemId,
560) -> Vec<ItemId> {
561 if !selection.is_selected(grabbed) {
562 return vec![grabbed];
563 }
564 let selected = selection.selected();
565 if selected.len() < 2 {
566 return vec![grabbed];
567 }
568 model.selection_roots(&selected)
569}
570
571/// Snapshot of one item's hit-test geometry + handler closures used
572/// by the SceneView's `on_pointer_event` dispatch path. Refreshed
573/// per layout pass alongside `lightweight_bounds_snapshot`.
574#[derive(Clone)]
575struct HandlerSnapshotEntry {
576 id: crate::item::ItemId,
577 /// Scene-coord AABB used for broad-phase hit-test (normal items).
578 scene_rect: Rect,
579 /// Local→scene transform — used to inverse-project the
580 /// scene-coord pointer into local coords for the narrow phase.
581 /// Stored so the dispatch path doesn't have to re-walk the parent
582 /// chain (which would need `&Scene`).
583 scene_transform: teksilo_canvas::Transform2D,
584 /// The item's geometry in local coordinates, cloned straight from
585 /// [`SceneItem::shape`](crate::SceneItem::shape). One value, shared
586 /// with every other query in the crate — the dispatch path and the
587 /// eager `Scene::item_at` path cannot disagree about what an item is.
588 shape: ItemShape,
589 /// Where the entry sits in the view's single paint order. The snapshot is
590 /// sorted by this descending, so the first shape match is the entry the
591 /// user sees on top — band first, then z, then insertion.
592 key: PaintKey,
593 /// Whether this entry would *act* on a press ([`crate::pick::claims_press`]).
594 /// Read only by the `Over`-band veto; the hit test itself still resolves
595 /// the topmost **entry** and consults its handlers afterwards.
596 claims_press: bool,
597 /// Item-level handler closures, cloned at snapshot time. `None`
598 /// when the item has no handler set installed.
599 handlers: Option<Box<crate::item_handlers::SceneItemHandlerSet>>,
600 /// `true` when the item carries `ItemFlags::IGNORES_TRANSFORMATIONS`.
601 /// Dispatch routes hit-test through screen space: the visible
602 /// area is `local_bounds` rooted at the screen-projected
603 /// `scene_anchor`, and pan/zoom of the view don't change that
604 /// area. `scene_rect` is meaningless for these items because
605 /// they don't scale with zoom.
606 ignores_xform: bool,
607 /// For IGNORES items: the item's origin (local `(0,0)`) mapped
608 /// to scene coords through the parent chain. The current view
609 /// transform projects this to the screen-space anchor at
610 /// dispatch time. For normal items, unused.
611 scene_anchor: Point,
612 /// For IGNORES items: the item's `local_bounds`. Combined with
613 /// the screen anchor at dispatch time to form the screen-space
614 /// AABB. For normal items, unused.
615 local_bounds: Rect,
616}
617
618/// Hit-test geometry for one **draggable** lightweight item, snapshotted each
619/// layout pass for the `on_drag` drag-start hit-test and the grab-cursor hover
620/// check. Carries the item's [`ItemShape`] + transform (the same data
621/// `HandlerSnapshotEntry` holds for tap/hover) so a press targets the item on
622/// the item's **actual shape**, not merely its AABB — important for thin
623/// draggable items (e.g. a connector path) whose bounding box is much larger than
624/// the drawn stroke. z-sorted descending so the first shape match is the topmost.
625///
626/// A press that lands on no shape at all is offered to a *coarse* pointer's
627/// bounded slop pass ([`GrabSlop`]), which widens each item's box rather than its
628/// shape — but only by what a small target earns, so the middle of a large AABB
629/// is still not a hit.
630#[derive(Clone)]
631struct DraggableSnapshotEntry {
632 id: ItemId,
633 scene_rect: Rect,
634 scene_transform: teksilo_canvas::Transform2D,
635 shape: ItemShape,
636 ignores_xform: bool,
637 scene_anchor: Point,
638 local_bounds: Rect,
639 /// Where the entry sits in the view's single paint order; the snapshot is
640 /// sorted by this descending. See [`PaintKey`].
641 key: PaintKey,
642}
643
644impl std::fmt::Debug for DraggableSnapshotEntry {
645 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646 f.debug_struct("DraggableSnapshotEntry")
647 .field("id", &self.id)
648 .field("scene_rect", &self.scene_rect)
649 .finish_non_exhaustive()
650 }
651}
652
653/// The topmost draggable item whose **shape** contains the pointer (narrow
654/// phase), or the nearest one this pointer's slop reaches, or `None`.
655///
656/// Mirrors the `hit_handler_item` logic used for tap/hover dispatch: AABB
657/// broad-phase, then inverse-project to local and consult the item's
658/// [`ItemShape`], with a screen-space branch for `IGNORES_TRANSFORMATIONS`
659/// items. `snap` must be sorted by [`PaintKey`] descending (topmost first). A
660/// total miss falls through to `slop`'s miss-only pass, which is inert for a
661/// mouse.
662///
663/// `floor` is the paint-order floor: entries below it are invisible to this
664/// hit test. The drag path passes the key of whatever press claimant vetoed a
665/// card, so a press that only reached this view because of that veto cannot
666/// grab something the card was covering.
667fn hit_draggable_item(
668 snap: &[DraggableSnapshotEntry],
669 screen_pt: Point,
670 scene_pt: Point,
671 view_xform: teksilo_canvas::Transform2D,
672 slop: GrabSlop,
673 floor: PaintKey,
674) -> Option<ItemId> {
675 let view_scale = view_xform.m[0].hypot(view_xform.m[1]);
676 for entry in snap.iter() {
677 if entry.key < floor {
678 continue;
679 }
680 if entry.ignores_xform {
681 let screen_anchor = view_xform.apply_point(entry.scene_anchor);
682 let screen_rect = Rect::new(
683 screen_anchor.x + entry.local_bounds.x,
684 screen_anchor.y + entry.local_bounds.y,
685 entry.local_bounds.width,
686 entry.local_bounds.height,
687 );
688 if !screen_rect.contains(screen_pt) {
689 continue;
690 }
691 let local_pt = Point::new(screen_pt.x - screen_anchor.x, screen_pt.y - screen_anchor.y);
692 // Screen-anchored items ignore the view transform → unit scale.
693 if entry.shape.contains(local_pt, 1.0) {
694 return Some(entry.id);
695 }
696 continue;
697 }
698 if !entry.scene_rect.contains(scene_pt) {
699 continue;
700 }
701 let local_pt = entry
702 .scene_transform
703 .inverse()
704 .map(|inv| inv.apply_point(scene_pt))
705 .unwrap_or(Point::ZERO);
706 if entry.shape.contains(local_pt, view_scale) {
707 return Some(entry.id);
708 }
709 }
710 slop.nearest_draggable(snap, screen_pt, scene_pt, view_xform, floor.rank())
711}
712
713/// The topmost item under the pointer whose **shape** contains it, out of a
714/// handler snapshot, or `None`.
715///
716/// Two hit spaces, one per item kind. A normal item is broad-phased against its
717/// scene-coord AABB and narrow-phased by inverse-projecting the pointer into
718/// item-local coordinates; an `IGNORES_TRANSFORMATIONS` item is pinned at a
719/// screen position, so it is broad-phased against its projected screen rect and
720/// narrow-phased at unit scale. `snap` must be sorted by [`PaintKey`]
721/// descending, so the first containing entry is the topmost one.
722///
723/// `floor` is the paint-order floor, compared as a whole [`PaintKey`] so a
724/// caller can say "strictly above *this* entry" and not only "at least this
725/// rank". The dispatch site passes [`RANK_OVER`] (as a rank floor) when the
726/// arena has given this pointer to a heavyweight card — only the `Over` band
727/// outranks *every* card, so everything below it must stay invisible and let
728/// the card have the event — and [`RANK_UNDER`](crate::pick::RANK_UNDER) when the view itself is the
729/// target, which is precisely when no card won. The `accepts_child_hit` veto
730/// passes one specific card's key, because an
731/// [`Interleaved`](crate::SceneLayer::Interleaved) entry is above some cards
732/// and below others and a rank cannot say which.
733///
734/// A total miss falls through to `slop`'s miss-only pass, which is inert for a
735/// mouse.
736fn hit_handler_index(
737 snap: &[HandlerSnapshotEntry],
738 screen_pt: Point,
739 scene_pt: Point,
740 view_xform: teksilo_canvas::Transform2D,
741 slop: GrabSlop,
742 floor: PaintKey,
743) -> Option<usize> {
744 // Logical view zoom (uniform scale of the linear part) — passed to each
745 // item's shape test so a cosmetic (device-pixel) stroke's clickable band is
746 // converted to scene coordinates at the current zoom.
747 let view_scale = view_xform.m[0].hypot(view_xform.m[1]);
748 for (index, entry) in snap.iter().enumerate() {
749 if entry.key < floor {
750 continue;
751 }
752 if entry.ignores_xform {
753 let screen_anchor = view_xform.apply_point(entry.scene_anchor);
754 let screen_rect = Rect::new(
755 screen_anchor.x + entry.local_bounds.x,
756 screen_anchor.y + entry.local_bounds.y,
757 entry.local_bounds.width,
758 entry.local_bounds.height,
759 );
760 if !screen_rect.contains(screen_pt) {
761 continue;
762 }
763 let local_pt = Point::new(screen_pt.x - screen_anchor.x, screen_pt.y - screen_anchor.y);
764 // Screen-anchored items ignore the view transform → unit scale.
765 if entry.shape.contains(local_pt, 1.0) {
766 return Some(index);
767 }
768 continue;
769 }
770 if !entry.scene_rect.contains(scene_pt) {
771 continue;
772 }
773 let local_pt = entry
774 .scene_transform
775 .inverse()
776 .map(|inv| inv.apply_point(scene_pt))
777 .unwrap_or(Point::ZERO);
778 if entry.shape.contains(local_pt, view_scale) {
779 return Some(index);
780 }
781 }
782 slop.nearest_handler_index(snap, screen_pt, scene_pt, view_xform, floor.rank())
783}
784
785/// [`hit_handler_index`] resolved to an owned entry, for the dispatch path,
786/// which needs the handler closures and cannot hold the snapshot borrow across
787/// a callback.
788///
789/// The index form is the one the `Over`-band veto uses: it asks only whether
790/// the winner claims the press, and cloning an entry to answer that would put a
791/// heap allocation (the boxed handler set) on every pointer sample — on a path
792/// whose whole reason for existing is that it allocates nothing.
793fn hit_handler_item(
794 snap: &[HandlerSnapshotEntry],
795 screen_pt: Point,
796 scene_pt: Point,
797 view_xform: teksilo_canvas::Transform2D,
798 slop: GrabSlop,
799 floor: PaintKey,
800) -> Option<HandlerSnapshotEntry> {
801 hit_handler_index(snap, screen_pt, scene_pt, view_xform, slop, floor)
802 .map(|index| snap[index].clone())
803}
804
805/// The pointer-kind half of the grab hit test: a build-time
806/// [`InputTokens`](teksilo_tokens::InputTokens)
807/// snapshot plus the kind of the pointer the event being handled came from.
808///
809/// Carried as one value so the exact-pass hit tests take one extra argument
810/// rather than two, and so the *miss-only* rule has a single place to live:
811/// every method here runs after an exact pass has already missed, and none of
812/// them can report a hit an exact pass had found.
813#[derive(Debug, Clone, Copy)]
814struct GrabSlop {
815 tokens: teksilo_tokens::InputTokens,
816 kind: teksilo_tokens::PointerKind,
817}
818
819impl GrabSlop {
820 /// The slop a pointer of `kind` earns against `tokens`.
821 fn new(tokens: teksilo_tokens::InputTokens, kind: teksilo_tokens::PointerKind) -> Self {
822 Self { tokens, kind }
823 }
824
825 /// Whether this pointer can earn no widening at all — `true` for a mouse at
826 /// every density, since the mouse profile's slop radius is `0.0`. Asked
827 /// first in each pass, so a mouse never walks the snapshot.
828 fn offers_nothing(&self) -> bool {
829 teksilo_core::pointer::hit_slop::HitSlop::for_pointer(self.kind, &self.tokens).is_none()
830 }
831
832 /// The tap-versus-drag movement tolerance for this pointer, in view pixels.
833 fn tap_tolerance_px(&self) -> f32 {
834 tap_tolerance_px(&self.tokens, self.kind)
835 }
836
837 /// The nearest draggable item whose *inflated* bounds contain the press.
838 ///
839 /// Deliberately the inflated **box**, not the shape: an item whose shape is
840 /// narrower than its box — a connector stroke, a diagonal path — is exactly
841 /// the case this pass exists for, and re-testing the shape against a widened
842 /// point would answer the same "no" the exact pass just did.
843 fn nearest_draggable(
844 &self,
845 snap: &[DraggableSnapshotEntry],
846 screen_pt: Point,
847 scene_pt: Point,
848 view_xform: teksilo_canvas::Transform2D,
849 min_rank: u8,
850 ) -> Option<ItemId> {
851 if self.offers_nothing() {
852 return None;
853 }
854 let view_scale = view_xform.m[0].hypot(view_xform.m[1]);
855 let mut best: Option<(f32, ItemId)> = None;
856 for entry in snap.iter() {
857 if entry.key.rank() < min_rank {
858 continue;
859 }
860 let Some(distance) = self.miss_distance(
861 entry.ignores_xform,
862 entry.scene_rect,
863 entry.local_bounds,
864 entry.scene_anchor,
865 screen_pt,
866 scene_pt,
867 view_xform,
868 view_scale,
869 ) else {
870 continue;
871 };
872 if best.is_none_or(|(best_d, _)| distance < best_d) {
873 best = Some((distance, entry.id));
874 }
875 }
876 best.map(|(_, id)| id)
877 }
878
879 /// Distance from the press to `entry`'s bounds, in the space the comparison
880 /// runs in, or `None` when the press is further away than the slop this
881 /// entry earns.
882 ///
883 /// A screen-anchored item is measured in view pixels at unit scale; every
884 /// other item is measured in **scene** units against its scene-space box,
885 /// with the earned slop converted by the live zoom — so a rotated view needs
886 /// no transformed-box arithmetic and the tolerance is still the same number
887 /// of view pixels at every zoom.
888 #[allow(clippy::too_many_arguments)]
889 fn miss_distance(
890 &self,
891 ignores_xform: bool,
892 scene_rect: Rect,
893 local_bounds: Rect,
894 scene_anchor: Point,
895 screen_pt: Point,
896 scene_pt: Point,
897 view_xform: teksilo_canvas::Transform2D,
898 view_scale: f32,
899 ) -> Option<f32> {
900 if ignores_xform {
901 let anchor = view_xform.apply_point(scene_anchor);
902 let screen_rect = Rect::new(
903 anchor.x + local_bounds.x,
904 anchor.y + local_bounds.y,
905 local_bounds.width,
906 local_bounds.height,
907 );
908 let slop = grab_slop_px(&self.tokens, self.kind, screen_rect.size());
909 let distance = distance_to_rect(screen_pt, screen_rect);
910 return (slop > 0.0 && distance <= slop).then_some(distance);
911 }
912 if !(view_scale.is_finite() && view_scale > 1e-6) {
913 return None;
914 }
915 let screen_size = Size::new(
916 scene_rect.width * view_scale,
917 scene_rect.height * view_scale,
918 );
919 let slop_scene = grab_slop_px(&self.tokens, self.kind, screen_size) / view_scale;
920 let distance = distance_to_rect(scene_pt, scene_rect);
921 (slop_scene > 0.0 && distance <= slop_scene).then_some(distance)
922 }
923
924 /// The nearest item whose *inflated* bounds contain the press, out of a
925 /// handler snapshot, as a position in it. The tap/hover twin of
926 /// [`nearest_draggable`](Self::nearest_draggable), with the same miss-only
927 /// and inflated-box rules.
928 fn nearest_handler_index(
929 &self,
930 snap: &[HandlerSnapshotEntry],
931 screen_pt: Point,
932 scene_pt: Point,
933 view_xform: teksilo_canvas::Transform2D,
934 min_rank: u8,
935 ) -> Option<usize> {
936 if self.offers_nothing() {
937 return None;
938 }
939 let view_scale = view_xform.m[0].hypot(view_xform.m[1]);
940 let mut best: Option<(f32, usize)> = None;
941 for (index, entry) in snap.iter().enumerate() {
942 if entry.key.rank() < min_rank {
943 continue;
944 }
945 let Some(distance) = self.miss_distance(
946 entry.ignores_xform,
947 entry.scene_rect,
948 entry.local_bounds,
949 entry.scene_anchor,
950 screen_pt,
951 scene_pt,
952 view_xform,
953 view_scale,
954 ) else {
955 continue;
956 };
957 if best.is_none_or(|(best_d, _)| distance < best_d) {
958 best = Some((distance, index));
959 }
960 }
961 best.map(|(_, index)| index)
962 }
963
964 /// The scene-unit radius within which a magnet handle may be grabbed.
965 ///
966 /// The declared [`MagnetismConfig::capture_px`](crate::MagnetismConfig::capture_px)
967 /// is the whole radius for a mouse; every other pointer adds what a disc of
968 /// that diameter earns from its own profile — nothing for a mouse, a pen's
969 /// small allowance for a pen, the full offer for a finger — so a port dot
970 /// stays grabbable without any app-side number. The four *snap-arrival* radii
971 /// are left alone: they are how close a dragged thing has to come before it
972 /// snaps, which is feel, not reach.
973 fn magnet_grab_scene_radius(&self, capture_px: f32, zoom: f32) -> f32 {
974 let diameter = (capture_px * 2.0).max(0.0);
975 let px = capture_px + grab_slop_px(&self.tokens, self.kind, Size::new(diameter, diameter));
976 px / zoom
977 }
978}
979
980/// Visual debug overlays painted on top of normal scene rendering.
981///
982/// Every flag defaults to `false`. Use this to verify that culling /
983/// hit-test / spatial-index / dragging are doing what you expect
984/// while developing a scene-based feature; turn off before shipping.
985///
986/// Each flag adds a thin overlay paint with a distinct color so
987/// multiple flags can be combined without visual confusion:
988///
989/// - [`item_bounds`](Self::item_bounds): green outline around every
990/// visible scene item's `bounds_in_scene`.
991/// - [`content_bounds`](Self::content_bounds): blue outline around
992/// the scene's overall content extent (the union of all item
993/// bounds).
994/// - [`viewport`](Self::viewport): red outline around the visible
995/// scene region (the cull rect — the inverse-projected viewport).
996/// - [`selection_bounds`](Self::selection_bounds): orange outline
997/// around every currently-selected item.
998#[derive(Debug, Clone, Copy, Default)]
999pub struct DebugOverlay {
1000 pub item_bounds: bool,
1001 pub content_bounds: bool,
1002 pub viewport: bool,
1003 pub selection_bounds: bool,
1004}
1005
1006impl DebugOverlay {
1007 /// All overlays enabled. Useful to catch any anomaly visually.
1008 pub const ALL: DebugOverlay = DebugOverlay {
1009 item_bounds: true,
1010 content_bounds: true,
1011 viewport: true,
1012 selection_bounds: true,
1013 };
1014
1015 /// Whether at least one debug overlay is enabled.
1016 pub fn is_active(&self) -> bool {
1017 self.item_bounds || self.content_bounds || self.viewport || self.selection_bounds
1018 }
1019}
1020
1021/// Direction passed to a [`SceneView::focus_order`] callback when the
1022/// app wants to override the default Tab cycle.
1023///
1024/// `Forward` corresponds to Tab; `Backward` to Shift+Tab. The default
1025/// SceneView focus traversal is scene insertion order — apps that
1026/// need data-flow order (graph editor), story-order (corkboard with
1027/// Acts), chronological order (timeline), etc. install a callback
1028/// that receives the current focus and returns the next id.
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub enum FocusDirection {
1031 /// Advance to the next item — corresponds to the Tab key.
1032 Forward,
1033 /// Retreat to the previous item — corresponds to Shift+Tab.
1034 Backward,
1035}
1036
1037/// A pannable/zoomable viewport that renders a [`Scene`]'s items at scene
1038/// coordinates and routes user input (scroll, pinch, drag, keyboard) back into
1039/// the camera signals.
1040///
1041/// Construct with [`SceneView::new`] (single-view sugar: wraps a [`Scene`] in a
1042/// fresh [`SceneModel`]) or [`SceneView::with_model`] (multi-view: several
1043/// viewports share one [`SceneModel`] and each reconcile independently on every
1044/// mutation). Install a heavyweight builder for delegated items via
1045/// [`delegate_typed`](Self::delegate_typed). Add to a [`WidgetTree`](teksilo_core::widget_tree::WidgetTree)
1046/// like any other widget; gestures and camera animations are wired automatically
1047/// during [`build`](teksilo_core::widget::Widget::build).
1048///
1049/// See the [module-level documentation](crate) for the full composition model
1050/// and `docs/teksilo-scene.md` for an end-to-end guide.
1051pub struct SceneView {
1052 /// Shared, cloneable handle to the scene this view renders. Multiple
1053 /// `SceneView`s can hold clones of one [`SceneModel`] and reconcile
1054 /// independently on every mutation.
1055 model: SceneModel,
1056 /// Per-view heavyweight builder for `Delegated` items. Each view calls
1057 /// its own delegate with an item's type-erased payload to build a fresh
1058 /// `Widget` instance for **this** view's arena. Returns `None` to skip
1059 /// an item (e.g. a downcast miss). `None` (the field) = no delegate
1060 /// installed; only single-view `Once` widgets materialise.
1061 delegate: Option<Rc<dyn Fn(&dyn std::any::Any, ItemId) -> Option<Box<dyn Widget>>>>,
1062 /// Items whose payload changed since the last build (filled by the
1063 /// `item_change` observer on `ItemChange::PayloadChanged`). Drained at
1064 /// the top of `build`, where each is destroyed and re-materialised via the
1065 /// delegate. `Rc<RefCell>` so the observer closure can push without
1066 /// borrowing `model`.
1067 payload_dirty: Rc<RefCell<HashSet<ItemId>>>,
1068 /// Materialisation map populated during `build`. Stable across
1069 /// rebuilds — subsequent `build` calls just return the cached
1070 /// widget ids.
1071 materialized: HashMap<ItemId, WidgetId>,
1072 /// Reverse lookup populated alongside `materialized` so the
1073 /// per-frame `place_children` cull resolves
1074 /// `WidgetId → ItemId` in `O(1)`. Without it, scaling the demo
1075 /// to 5,000 cards would burn a full frame's budget on the
1076 /// per-child entry scan.
1077 widget_to_item: HashMap<WidgetId, ItemId>,
1078 /// The heavyweight cards this view publishes to assistive technology, as
1079 /// decided by the most recent `place_children`. `None` until the view has
1080 /// been laid out once — "no decision yet", which means publish everything.
1081 ///
1082 /// A **subset** of the cards the same pass kept alive, and deliberately so.
1083 /// Staying alive and being enumerated are two different questions with two
1084 /// different answers: [`retention_margin`](SceneView::retention_margin) is
1085 /// a lifecycle hint that keeps a card just off the edge warm so the camera
1086 /// never reaches a hole, while
1087 /// [`a11y_off_screen_mode`](SceneView::a11y_off_screen_mode) is the app's
1088 /// statement about how much of an off-screen scene a screen reader should
1089 /// be offered. A card in the margin band but outside the mode's region is
1090 /// alive — laid out, Tab-reachable, holding its state — and absent from
1091 /// the AT tree.
1092 ///
1093 /// The one exception is a card the user is *in the middle of*: it is
1094 /// pinned into this set wherever the camera goes, because the published
1095 /// tree names the focused node and a focus pointing at a node the walk did
1096 /// not emit is a broken tree, not a missing one.
1097 ///
1098 /// Written by the layout pass and read by the accessibility walk so the
1099 /// two cannot disagree about a card: the walk asks the recorded set rather
1100 /// than recomputing a predicate that might round differently or miss the
1101 /// pins. Paired with [`at_children`](Self::at_children), written in the
1102 /// same loop from the same decision.
1103 at_heavy: Rc<RefCell<Option<HashSet<ItemId>>>>,
1104 /// The same decision as [`at_heavy`](Self::at_heavy), as the ordered arena
1105 /// child list `Widget::accessibility_children` hands the framework walker.
1106 ///
1107 /// This is what actually suppresses a live-but-unenumerated card: the
1108 /// walker uses this list for BOTH the child push and the recursion, so a
1109 /// card left out of it is neither named by a parent nor emitted as a node
1110 /// — no orphan, no dangling child, nothing for the framework's strip to
1111 /// clean up after. Order is the arena's own child order (z-order), so what
1112 /// a screen reader reads is unchanged apart from the omissions.
1113 at_children: Rc<RefCell<Option<Vec<WidgetId>>>>,
1114 /// How far past the viewport edge, in **screen** pixels, a heavyweight
1115 /// card stays live. See [`SceneView::retention_margin`].
1116 retention_margin: f32,
1117 /// Live mirror of `bounds.origin` (the SceneView's screen-space
1118 /// position as decided by its parent layout). Updated in
1119 /// `place_children` and folded into the view-transform composition
1120 /// so a SceneView positioned at a non-zero parent offset still
1121 /// places its children correctly under pan / zoom / rotation.
1122 /// Without this, zoom would multiply `bounds.origin` and the
1123 /// content would visually drift away from the viewport.
1124 bounds_origin_signal: Signal<Vec2>,
1125 /// Fallback size when the parent's `SizeProposal` is unspecified
1126 /// on either axis.
1127 default_size: Size,
1128 /// When `true`, [`SceneView::layout_response`] returns the
1129 /// scene's `scene_rect_extent` as the view's wanted size — the
1130 /// view sizes itself to its scene. User pan / zoom / drag-to-
1131 /// move are still gated by [`Scene::pan_axes`] and
1132 /// [`Scene::is_zoomable`]; the default policy is "no pan, no
1133 /// zoom" because the entire scene is already on-screen.
1134 adopt_scene_size: bool,
1135 /// Drag-on-canvas behavior. Default `RubberBand` (item drag →
1136 /// move; empty area → marquee). `ScrollHandDrag` makes the
1137 /// canvas pan unconditionally on left-mouse drag; `NoDrag`
1138 /// disables the on-drag handler entirely.
1139 /// Drag mode (rubber-band marquee, scroll-hand pan, or no-drag).
1140 /// Reactive: gesture handlers read this per event, so mutating
1141 /// the signal at runtime (typically from a toolbar) flips
1142 /// behaviour on the next pointer event without rebuilding
1143 /// the view. `.drag_mode(mode)` writes to it directly;
1144 /// `.drag_mode(sig)` replaces the inner signal with an
1145 /// app-owned one so toolbars can share state with the view.
1146 drag_mode: Signal<crate::item_handlers::DragMode>,
1147 /// Per-layout snapshot of (id, scene_rect, handlers) for items
1148 /// that have a handler set installed. Used by the
1149 /// `on_pointer_event` closure to dispatch hover / tap / context
1150 /// menu without borrowing `&self.scene`. Refreshed in
1151 /// `layout_response`.
1152 handler_snapshot: Rc<RefCell<Vec<HandlerSnapshotEntry>>>,
1153 /// Currently-hovered item id, used to dispatch `on_hover(false)`
1154 /// when the pointer leaves it.
1155 hovered_item: Rc<Cell<Option<crate::item::ItemId>>>,
1156 /// Last press recorded for tap detection: `(press point in VIEW pixels,
1157 /// item_id, button)`. Cleared on PointerUp / PointerLeave.
1158 ///
1159 /// View pixels rather than scene units so the tap-versus-drag tolerance is
1160 /// the same physical distance at every zoom, and so a zoom that changes
1161 /// between the press and the release cannot make the two points
1162 /// incomparable — see [`TAP_MOVEMENT_THRESHOLD`].
1163 pending_tap: Rc<
1164 Cell<
1165 Option<(
1166 Point,
1167 crate::item::ItemId,
1168 teksilo_core::event::PointerButton,
1169 )>,
1170 >,
1171 >,
1172 /// Latest viewport size observed during layout. Cached so
1173 /// imperative methods like [`SceneView::fit_to_content`] can
1174 /// reason about the visible rectangle without re-running layout.
1175 /// `Rc<Cell>` so event-handler closures (e.g. Ctrl+wheel zoom-
1176 /// about-viewport-center) can read it without touching `&mut self`.
1177 /// Last viewport size resolved by `layout_response`. Stored as a
1178 /// `Signal` (not `Cell`) so derived signals like
1179 /// [`viewport_in_scene_signal`](Self::viewport_in_scene_signal)
1180 /// can react to viewport changes. Writes are gated by an
1181 /// equality check at the call site to avoid notifying on
1182 /// unchanged values.
1183 last_viewport: Signal<Size>,
1184
1185 // --- View transform state ---------------------------------
1186 pan_x: Signal<f32>,
1187 pan_y: Signal<f32>,
1188 zoom: Signal<f32>,
1189 rotation: Signal<f32>,
1190
1191 // --- View configuration ----------------------------------------
1192 /// View-level *tightening* override on the underlying
1193 /// [`Scene`]'s zoom range. The effective clamp applied at
1194 /// gesture / set_zoom / pan_to time is the intersection of
1195 /// `Scene::current_zoom_range()` and this override (see
1196 /// `effective_zoom_range`). `None` means the view does not
1197 /// constrain zoom; the default is `Some(0.1..=10.0)` so
1198 /// existing callers see the historical clamp behaviour.
1199 zoom_range_override: Signal<Option<std::ops::RangeInclusive<f32>>>,
1200 /// View-level *tightening* override on the underlying
1201 /// [`Scene`]'s pan bounds. The effective clamp is the rect
1202 /// intersection of `Scene::current_pan_bounds()` and this
1203 /// override. `None` (the default) leaves pan unconstrained
1204 /// from the view side.
1205 pan_bounds_override: Signal<Option<Rect>>,
1206 pan_anim_duration: Duration,
1207 zoom_anim_duration: Duration,
1208 line_height: f32,
1209 /// Whether a wheel that the scene can't absorb (already clamped at its
1210 /// `pan_bounds`) chains to an ancestor scrollable, or is contained.
1211 overscroll_behavior: OverscrollBehavior,
1212
1213 // --- A11y configuration — visual-default path ----------------------------------
1214 a11y_off_screen_mode: crate::a11y::A11yOffScreenMode,
1215
1216 // --- A11y configuration — logical structural API ----------------------------------
1217 /// Cooperative (default) vs StrictlyParallel.
1218 a11y_mode: crate::a11y::A11yMode,
1219 /// SceneView's own arena `WidgetId`, captured during the first
1220 /// `build()`. Needed by `a11y_redirect_descendant` to compute
1221 /// the synthetic `NodeId` of a declared logical parent group
1222 /// (the hash key is `(self_id, group_id, SyntheticKind::SceneGroup)`).
1223 /// `Cell` because the trait method is `&self`.
1224 self_widget_id: Cell<Option<WidgetId>>,
1225
1226 // --- Interactivity ------------------------------------------------
1227 /// When `false`, `build()` skips registering scroll / pinch /
1228 /// keyboard handlers and does not mark the SceneView focusable.
1229 /// Programmatic `pan_to` / `zoom_to` still work — this only
1230 /// gates user-driven navigation. Used by chart-style nested
1231 /// scenes where the outer container is purely decorative
1232 /// (axis chrome around an inner data SceneView).
1233 interactive: bool,
1234
1235 // --- Selection -----------------------------------------
1236 /// Reactive selection state. Defaults to `SceneSelectionMode::None`
1237 /// (no selection wired). Apps opt in via
1238 /// [`selection_mode`](Self::selection_mode); marquee + click-to-
1239 /// select then activate.
1240 selection: crate::selection::SceneSelection,
1241 /// In-flight marquee state: scene-coord origin + current. While
1242 /// `Some`, `paint` overlays a semi-transparent rect.
1243 /// `Rc<Cell>` so the on_drag closure (which only borrows
1244 /// `&self` shape via the closure's capture) can mutate it.
1245 marquee: Rc<Cell<Option<MarqueeState>>>,
1246 /// Pending marquee commit: set by the on_drag closure on
1247 /// `DragPhase::Ended`, consumed at the start of the next
1248 /// `place_children` (which has direct `&self.scene` access
1249 /// via `self`). This indirection avoids forcing `Scene` into
1250 /// an `Rc<RefCell>`.
1251 pending_marquee_commit: Rc<RefCell<Option<(SceneRegion, ItemSelectionMode, bool)>>>,
1252 /// Which rule the rubber band picks items by. Default
1253 /// [`ItemSelectionMode::IntersectsItemShape`] — see
1254 /// [`SceneView::marquee_selection_mode`].
1255 marquee_mode: ItemSelectionMode,
1256 /// In-flight drag-to-move state: which item is being dragged
1257 /// and the scene-coord anchor where the drag started. The
1258 /// total scene-coord delta is computed at `Ended` from
1259 /// `current - anchor` and posted to `pending_item_move`.
1260 /// `Rc<Cell>` so the on_drag closure can mutate via `&self`.
1261 drag_target: Rc<Cell<Option<DragTarget>>>,
1262 /// Pending drag-to-move commit: `(target_id, delta)` set by
1263 /// the on_drag `Ended` branch, drained in `build`. The drain
1264 /// code translates every item in
1265 /// [`drag_group`](Self::drag_group) by the same delta; a
1266 /// descendant (declared via `Scene::set_item_parent`) follows
1267 /// its ancestor because its `local_pos` is relative to it
1268 /// — so a labelled rectangle (Rect parent + TextItem child)
1269 /// moves as one unit, QGraphicsScene-style.
1270 pending_item_move: Rc<Cell<Option<(ItemId, Vec2)>>>,
1271 /// Snapshot of **draggable** lightweight scene items + their narrow-phase
1272 /// hit geometry, used by the on_drag drag-start hit-test and the grab-cursor
1273 /// hover check. Brought up to date in `layout_response` (not
1274 /// `place_children`, which never runs for a scene of only lightweight
1275 /// items) — the snapshot stays consistent within a single drag, and
1276 /// between drags is patched or rebuilt from the `item_change_signal`
1277 /// stream. Keeps the dispatch path off the model's `RefCell`, which an
1278 /// `ItemChange` observer may already hold.
1279 lightweight_bounds_snapshot: Rc<RefCell<Vec<DraggableSnapshotEntry>>>,
1280 /// Bumped by the on_drag closure on `Ended` after posting a
1281 /// `pending_item_move`. SceneView binds to this at
1282 /// `BindingLevel::Rebuild` in `build`, so the next build
1283 /// cycle drains the pending move and calls
1284 /// `Scene::set_local_pos` (which requires `&mut self.scene`,
1285 /// only available inside `build`). Without this signal, the
1286 /// move was queued but never applied — items "snapped back"
1287 /// to their original positions on drag release.
1288 reconcile_dirty: Signal<u64>,
1289
1290 /// Bumped by the `item_change_signal` observer on an
1291 /// [`ItemChange::AppearanceChanged`](crate::ItemChange::AppearanceChanged).
1292 /// Bound at `BindingLevel::RepaintOnly` in `build`, so a lightweight item's
1293 /// live colour/style change repaints the view (re-running `paint_band` →
1294 /// `item.paint`) **without** a relayout or rebuild — the cheap path for a
1295 /// pure appearance mutation.
1296 appearance_dirty: Signal<u64>,
1297
1298 /// Bumped by the `item_change_signal` observer on an
1299 /// [`ItemChange::MeasuredSizeChanged`](crate::ItemChange::MeasuredSizeChanged).
1300 /// Bound at `BindingLevel::Relayout` in `build`.
1301 ///
1302 /// **Relayout, not rebuild**, and that is the whole point of the separate
1303 /// signal. A measured size moves geometry and nothing else: nothing was
1304 /// materialised, nothing reaped, no delegate needs re-running, and the
1305 /// change is counted out of `structural_version` so it buys no AccessKit
1306 /// re-walk either. Answering a paragraph's re-wrap with a full
1307 /// `SceneView::build()` — three observers re-installed, every drain re-run,
1308 /// the heavyweight list re-derived and re-sorted — is the storm this exists
1309 /// to avoid, and a note page produces one per line break.
1310 ///
1311 /// The view that *made* the write is notified too, and that is deliberate:
1312 /// it is what makes a **second** view of the same model relayout at all.
1313 /// The writer's extra pass is bounded by the equality guard in
1314 /// `Scene::set_measured_size` — it measures the same height, writes
1315 /// nothing, and stops.
1316 measure_dirty: Signal<u64>,
1317
1318 /// Per-item measurement history for the non-[`Fixed`](crate::SizePolicy)
1319 /// size policies, and the convergence guard.
1320 ///
1321 /// Per **view**, not per model: the measurement comes from a widget
1322 /// instance, and `delegate_typed` builds one instance per view.
1323 ///
1324 /// The last answer, the width it was taken at, and how often the body has
1325 /// contradicted itself since anything but this view last reached it. A
1326 /// body whose `layout_response` is not a function of its width alone would
1327 /// otherwise write A, then B, then A forever, each write a relayout. See
1328 /// [`MeasureTrack`] for how an oscillation is told apart from an edit, and
1329 /// [`SizePolicy`](crate::SizePolicy) for the obligation itself.
1330 ///
1331 /// Behind an `Rc` because two things hold it: the (`&self`) layout pass
1332 /// writes it, and the `item_change_signal` observer clears an entry's
1333 /// history when its policy changes or its item is removed — a closure that
1334 /// outlives the `&self` that installed it.
1335 measure_state: Rc<RefCell<HashMap<ItemId, MeasureTrack>>>,
1336
1337 /// Latest pointer position seen on the SceneView (screen-space).
1338 /// Updated via an on_pointer_event handler in `build`. Used by
1339 /// Ctrl+wheel zoom to zoom-about-pointer instead of zoom-about-
1340 /// viewport-center, which is the natural feel users expect (the
1341 /// scene point under the cursor stays put).
1342 /// `None` until the first pointer event arrives — Ctrl+wheel
1343 /// before any pointer event falls back to viewport center.
1344 cursor_pos: Rc<Cell<Option<Point>>>,
1345
1346 /// App-supplied focus-order callback. When set, the public
1347 /// [`next_focus`](Self::next_focus) /
1348 /// [`previous_focus`](Self::previous_focus) accessors route
1349 /// through it instead of falling back to insertion order.
1350 /// `Rc<dyn Fn>` so callers can clone the SceneView while
1351 /// keeping the closure shared.
1352 focus_order_callback:
1353 Option<Rc<dyn Fn(&Scene, FocusDirection, Option<ItemId>) -> Option<ItemId>>>,
1354
1355 /// Whether this SceneView is logically nested inside another
1356 /// (chart-style outer chrome + inner data scene, or a preview
1357 /// pane inside a parent scene). Default `false` — every
1358 /// SceneView reports itself as a top-level `Role::Pane`. When
1359 /// `true`, the AT walker reports `Role::Region` instead so
1360 /// screen readers don't announce redundant landmarks.
1361 a11y_nested: bool,
1362 /// Optional label announced as the SceneView's own AT name.
1363 /// When set, becomes the logical region name (e.g. "Chart
1364 /// data area" for an inner chart SceneView). Default `None`
1365 /// — the SceneView has no explicit name.
1366 a11y_label: Option<LocalizedString>,
1367 /// Debug overlay configuration. Default: all flags `false`
1368 /// — no debug paint. When any flag is set, the SceneView
1369 /// paints visual diagnostics (item bounding boxes,
1370 /// content extent, viewport rect, etc.) on top of normal
1371 /// scene rendering. Use to verify culling, hit-test, and
1372 /// spatial-index behavior; intended for development only,
1373 /// don't ship with this on.
1374 debug_overlay: DebugOverlay,
1375
1376 // --- Cached derived signals ---------------------------------------
1377 /// `view_transform` as a derived `Signal<Transform2D>`,
1378 /// constructed once in `new()` and reused across rebuilds.
1379 /// Exposed via [`view_transform_signal`](Self::view_transform_signal)
1380 /// so consumers (e.g. axis labels in a parent SceneView) can
1381 /// bind to it reactively without taking a snapshot every paint.
1382 view_transform_signal: Signal<Transform2D>,
1383
1384 // --- Background / foreground paint hooks --------------------------
1385 /// App-supplied closure painted **before** the items walk. The
1386 /// canvas already has the view-transform scope pushed, so the
1387 /// closure paints in scene coords. The `Rect` argument is the
1388 /// scene-coord visible region — useful for "every-N-units" tiled
1389 /// backgrounds (graph-paper grids, ruled lines, dot grids) so the
1390 /// closure only emits geometry the user can actually see.
1391 background_paint: Option<Rc<dyn Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect)>>,
1392 /// App-supplied closure painted **after** the items walk and the
1393 /// marquee, but before the debug overlay. Same coordinate
1394 /// conventions as `background_paint`. Used for scene-coord
1395 /// chrome that should ride over content (rulers, snap-line
1396 /// indicators, drop hints).
1397 foreground_paint: Option<Rc<dyn Fn(&mut teksilo_canvas::Canvas, &PaintContext, Rect)>>,
1398
1399 // --- Item-coordinate paint cache ----------------------------------
1400 /// Per-item paint cache for items that opted into
1401 /// [`CacheMode::ItemCoordinate`](crate::cache::CacheMode::ItemCoordinate).
1402 /// Keyed by `ItemId`; the entry stores a [`RenderFrame`](teksilo_canvas::RenderFrame)
1403 /// recorded in the item's local coordinates and replayed via
1404 /// `Canvas::draw_render_frame` when valid. Invalidated by an
1405 /// observer on [`Scene::item_change_signal`](crate::Scene::item_change_signal):
1406 /// `Removed` / `LocalBoundsChanged` / `ItemReplaced` /
1407 /// `AppearanceChanged` / `MeasuredSizeChanged`, and an `IS_ENABLED`
1408 /// flip in `FlagsChanged`, drop that id's entry — position,
1409 /// transform, opacity, `z` and layer do not, being re-applied as
1410 /// wrapping scopes at replay. Apps that mutate item-internal state
1411 /// outside of `Scene` mutators must call
1412 /// [`SceneView::invalidate_item_cache`] to evict.
1413 pub(crate) item_cache: Rc<RefCell<crate::cache::ItemCoordinateCache>>,
1414 /// The viewport in scene coordinates, republished every `place_children`
1415 /// and every `paint`, so a child paint node can read it.
1416 ///
1417 /// A `SceneBandProxy` or a `WetLayerNode` knows its own rect and not the
1418 /// viewport's, and an item reads the region as a hint to skip geometry it
1419 /// cannot show. See [`ScenePaintBridge`](paint_node::ScenePaintBridge).
1420 published_visible_region: Rc<Cell<Rect>>,
1421 /// Every child's [`PaintKey`], as of the last build.
1422 ///
1423 /// Read on the pointer's hot path by
1424 /// [`accepts_child_hit`](SceneView::accepts_child_hit), which runs inside
1425 /// the router where the scene's `RefCell` may already be held by an
1426 /// `ItemChange` observer — so the key has to be here rather than looked up
1427 /// in the model. Written from `build`, which is also where the child order
1428 /// is decided from the same values, so the list the arena paints and the
1429 /// keys this compares are one snapshot.
1430 child_keys: Rc<RefCell<HashMap<WidgetId, PaintKey>>>,
1431 /// One paint node per [`SceneLayer::Interleaved`](crate::SceneLayer) item,
1432 /// keyed by item — the lightweight twin of `materialized`.
1433 interleaved_nodes: HashMap<ItemId, WidgetId>,
1434 /// The wet surface, if one is installed. Its node is always the last child
1435 /// of this view. See [`WetLayer`](paint_node::WetLayer).
1436 wet_layer: Option<paint_node::WetLayer>,
1437 /// The wet surface's node, minted in `build`.
1438 wet_node: Option<WidgetId>,
1439 /// This view's mount of [`wet_layer`](Self::wet_layer): the node above plus
1440 /// the window its tree belongs to.
1441 ///
1442 /// Held **strongly** here and weakly by the layer, which is what makes one
1443 /// layer in several views work without a `Drop` impl on this type: a
1444 /// destroyed view drops its mount and the layer's entry for it expires.
1445 wet_mount: Option<Rc<paint_node::WetMount>>,
1446 /// RAII guard for the cache-invalidation observer wired in
1447 /// `build()`. Held by `Self` so the observer's lifetime tracks
1448 /// the SceneView's; dropping it on a fresh `build()` un-installs
1449 /// the previous observer before re-installing.
1450 _item_cache_observer: RefCell<Option<teksilo_core::signal::ObserverHandle>>,
1451 /// RAII guard for the logical-AT-structure observer wired in `build()`.
1452 /// Held by `Self` so a re-build un-installs the previous observer before
1453 /// re-installing. Drives a reconcile pass on `Scene::a11y_change_signal`
1454 /// (group / parent / relation / live / landmark / category mutations),
1455 /// which don't flow through `item_change_signal`.
1456 _a11y_observer: RefCell<Option<teksilo_core::signal::ObserverHandle>>,
1457 /// [`Scene::mutation_version`] as of the end of the build that last
1458 /// requested an AccessKit re-walk. `None` until the first build. `build()`
1459 /// re-walks AT only when the version has advanced past this since the last
1460 /// walk (a structural / geometry / a11y mutation), so a `build()` driven
1461 /// purely by per-frame dynamic-bounds churn does not re-walk AT 60×/s.
1462 last_at_version: Option<u64>,
1463 /// Whether [`Scene::refresh_dynamic_bounds`] reported a change on the
1464 /// *previous* build. The `true → false` edge (an animation settling) walks
1465 /// the final animated bounds into AT once — the one AT update the
1466 /// version-delta gate would otherwise miss while suppressing the churn.
1467 dynamic_churning: bool,
1468
1469 // --- The above-a-card veto ----------------------------------------
1470 /// Whether the scene currently holds at least one hit-testable entry that
1471 /// **claims the press** ([`crate::pick::claims_press`]) and could be
1472 /// painted above a card — the [`Over`](crate::SceneLayer::Over) band, which
1473 /// is above every card, or the
1474 /// [`Interleaved`](crate::SceneLayer::Interleaved) band, which shares the
1475 /// cards' rank and is above the ones with a lower `z`. Refreshed while the
1476 /// handler snapshot is rebuilt.
1477 ///
1478 /// An `Under` item can never outrank a card, so this is the only reason
1479 /// this view would ever reject one of its own children — which is why every
1480 /// scene whose foreground is decorative pays one `bool` read per
1481 /// hit-tested child and nothing else.
1482 over_claimants: Rc<Cell<bool>>,
1483 /// Memo for [`topmost_press_claimant`], valid for one hit walk:
1484 /// `(snapshot generation, view transform, scene point) -> answer`.
1485 ///
1486 /// The arena asks [`accepts_child_hit`](SceneView::accepts_child_hit) once
1487 /// per hit-tested child, so without this a 200-card scene would rescan the
1488 /// snapshot 200 times per pointer sample. **The floor is deliberately not
1489 /// part of the key.** Every child asks about a different floor — its own
1490 /// [`PaintKey`] — so a memo keyed by it would miss on every single child
1491 /// and cost exactly the rescans it exists to prevent. What the scan answers
1492 /// is floor-free ("what is the topmost press claimant at this point?"), and
1493 /// the per-child floor is applied to that answer by one `PaintKey` compare
1494 /// outside the memo. See [`press_claimant_above`] for why the two forms are
1495 /// equivalent.
1496 ///
1497 /// The **whole** view transform is part of the key, not just the projected
1498 /// point. A screen-anchored (`IGNORES_TRANSFORMATIONS`) claimant is tested
1499 /// against its own projected anchor, which is a different point from the one
1500 /// being asked about, so two transforms agreeing at the query point can
1501 /// still disagree about the claimant. Six `f32` compares are cheaper than
1502 /// that being true only by luck.
1503 veto_memo: Rc<Cell<Option<(u64, [f32; 6], Point, Option<PaintKey>)>>>,
1504 /// How many times [`topmost_press_claimant`] has actually walked the
1505 /// handler snapshot, over this view's whole life.
1506 ///
1507 /// A memo is only worth what it hits, and "it hits" is not something
1508 /// reading the code can establish — the previous version of this memo was
1509 /// keyed by a value that differed on every call and therefore never hit at
1510 /// all, while looking exactly like a working memo. So the number is
1511 /// published rather than inferred, and
1512 /// `tests/veto_scaling_probe.rs` gates it: one hit test over *n*
1513 /// overlapping cards must scan once, not *n* times.
1514 veto_scans: Rc<Cell<u64>>,
1515 /// Bumped every time [`SceneView::child_paint_key`] is asked, which is the
1516 /// *other* half of the veto's cost and was invisible for exactly as long as
1517 /// the scan count was the only number published.
1518 ///
1519 /// The scan count cannot see it: the two halves fail independently, so
1520 /// breaking either alone leaves `veto_scaling_probe` green. This one gates
1521 /// the order of the two questions — the claimant is resolved first and the
1522 /// child's key looked up only against an actual answer, so a scene with no
1523 /// press claimant at all pays for **no** probes however many children the
1524 /// arena asks about. Evaluating the key as an argument instead reverses
1525 /// that, and nothing behavioural changes.
1526 key_probes: Rc<Cell<u64>>,
1527 /// Bumped every time either hit snapshot is written — rebuilt whole or
1528 /// patched per item. The first field of [`SceneView::veto_memo`]'s key, and
1529 /// therefore what retires that memo; a pass that leaves the snapshots alone
1530 /// (every pan sample) leaves it alone too.
1531 snapshot_generation: Rc<Cell<u64>>,
1532 /// What has happened to the model since the two hit snapshots above were
1533 /// last made current — the record that lets a layout pass patch them per
1534 /// item, or skip them entirely, instead of rebuilding from `scene.ids()`.
1535 ///
1536 /// Written by this view's `item_change_signal` observer (installed in
1537 /// `build`), read by the layout pass. See
1538 /// [`hit_snapshot`] for why that is sound.
1539 hit_sync: Rc<RefCell<hit_snapshot::HitSnapshotSync>>,
1540 /// The paint-order floor the arena's verdict established for the press
1541 /// currently in flight — [`RANK_OVER`] when a heavyweight card was on top
1542 /// at the press point (or an `Over` claimant vetoed one), [`RANK_UNDER`](crate::pick::RANK_UNDER)
1543 /// otherwise. Written by the `PointerDown` arm, read by the drag.
1544 ///
1545 /// The drag needs it and cannot derive it. `on_pointer_event` runs with the
1546 /// router's `dispatch_target` in hand; a recognized gesture does not always
1547 /// (two of its dispatch paths build a context with no target at all), and
1548 /// the press that armed the drag is anyway the event whose verdict the grab
1549 /// should follow. Recording it is what makes the drag and the tap answer
1550 /// from **one** paint order rather than two — and it is the reason a press
1551 /// on a card no longer grabs a connector the card was covering.
1552 ///
1553 /// One slot, and one press is what a `SceneView` arbitrates: the node-level
1554 /// `MultiContact` default is `First`, which refuses a second contact's
1555 /// gesture arena while the first is live, so only one press can be driving
1556 /// a drag here. Two pointers of different kinds can still *deliver* presses
1557 /// to this handler (a mouse and a pen), and the later one would overwrite
1558 /// the slot — which costs at most one drag starting with the floor the other
1559 /// press recorded, on a view that can only be dragging for one of them.
1560 /// Reset by the release and by the cancel arm.
1561 press_floor: Rc<Cell<PaintKey>>,
1562
1563 // --- Magnetism -----------------------------------------------------
1564 /// Per-view magnetism config (predicate, on_connect, feedback, …).
1565 /// `None` = magnetism off for this view: no snap, no feedback, no
1566 /// magnet AT nodes. Shared `Rc` so the drag / key closures hold a clone.
1567 magnetism: Option<Rc<MagnetismConfig>>,
1568 /// In-flight port-drag (grabbed a magnet handle, dragging a wire).
1569 /// `RefCell` (not `Cell`) because `PortDragState` carries a non-`Copy`
1570 /// `Rc` payload.
1571 port_drag: Rc<RefCell<Option<magnetism::PortDragState>>>,
1572 /// Active item-drag snap (the dragged item's magnet aligned onto a
1573 /// target). Drives feedback and the connection fired on release.
1574 item_snap: Rc<RefCell<Option<MagnetSnap>>>,
1575 /// Whether the keyboard connect mode is active (entered via the
1576 /// config's connect key while the view is focused).
1577 magnet_connect_mode: Rc<Cell<bool>>,
1578 /// The keyboard-focused magnet in connect mode (virtual focus: the
1579 /// SceneView keeps real arena focus and points `active_descendant`
1580 /// at this magnet's synthetic AT node).
1581 magnet_focus: Rc<Cell<Option<MagnetId>>>,
1582 /// The keyboard-activated source magnet awaiting a target.
1583 magnet_pending: Rc<Cell<Option<MagnetId>>>,
1584
1585 // --- Selection transform controller --------------------------------
1586 /// Per-view transform-controller config (handles, constraints, hooks).
1587 /// `None` = no controller: no frame, no handles, no group move, no
1588 /// accessibility nodes, and every pointer rule is exactly what it was.
1589 transform: Option<Rc<crate::transform_session::TransformConfig>>,
1590 /// The controller's live state — the in-flight session, the keyboard mode,
1591 /// the two tick signals and the published session signal. Shared `Rc` so
1592 /// the drag / key / paint closures hold a clone.
1593 transform_rt: Rc<crate::transform_session::TransformRuntime>,
1594}
1595
1596impl std::fmt::Debug for SceneView {
1597 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1598 // Manual impl: `focus_order_callback` is `Rc<dyn Fn>` and
1599 // therefore not `Debug`. Render it as a presence flag instead.
1600 f.debug_struct("SceneView")
1601 .field("model", &self.model)
1602 .field("materialized_count", &self.materialized.len())
1603 .field("default_size", &self.default_size)
1604 .field("interactive", &self.interactive)
1605 .field("zoom_range_override", &self.zoom_range_override.get())
1606 .field("pan_bounds_override", &self.pan_bounds_override.get())
1607 .field("a11y_mode", &self.a11y_mode)
1608 .field("a11y_off_screen_mode", &self.a11y_off_screen_mode)
1609 .field("selection_mode", &self.selection.mode())
1610 .field("focus_order_callback", &self.focus_order_callback.is_some())
1611 .field("a11y_nested", &self.a11y_nested)
1612 .field("a11y_label", &self.a11y_label)
1613 .field("transform_controller", &self.transform.is_some())
1614 .field("debug_overlay", &self.debug_overlay)
1615 .finish_non_exhaustive()
1616 }
1617}
1618
1619// --------------------------------------------------------------------------
1620// The `Over`-band veto — one order, two pickers, one answer
1621// --------------------------------------------------------------------------
1622
1623/// Every piece of in-flight pointer-interaction state a revoked contact must
1624/// give back, in one place.
1625///
1626/// A [`PointerCancel`](teksilo_core::event::WidgetEvent::PointerCancel) is
1627/// terminal — no `PointerUp` follows and nothing may activate — and it reaches
1628/// a `SceneView` twice: once as `DragPhase::Cancelled` (the recognizer's own
1629/// unwind, emitted only for a raw pointer cancel, since arbitration loss resets
1630/// an arena silently) and once as the node's `on_pointer_cancel`. Both hand
1631/// over to this, so there is one list of what a cancel drops rather than two
1632/// lookalike ones that can drift.
1633///
1634/// Clearing twice is a no-op, which is what makes the double delivery safe.
1635#[derive(Clone)]
1636pub(super) struct DragUnwind {
1637 pub(super) drag_target: Rc<Cell<Option<DragTarget>>>,
1638 pub(super) pending_item_move: Rc<Cell<Option<(ItemId, Vec2)>>>,
1639 pub(super) marquee: Rc<Cell<Option<MarqueeState>>>,
1640 pub(super) pending_marquee_commit: Rc<RefCell<Option<(SceneRegion, ItemSelectionMode, bool)>>>,
1641 pub(super) port_drag: Rc<RefCell<Option<magnetism::PortDragState>>>,
1642 pub(super) item_snap: Rc<RefCell<Option<MagnetSnap>>>,
1643 /// The transform controller, when one is installed. A revoked contact drops
1644 /// its live session the same way it drops a drag target, and for the same
1645 /// reason: the preview is re-derived from it on every paint and every
1646 /// relayout, so a session left standing would draw the selection at the
1647 /// transformed position for ever while the model went on saying nothing had
1648 /// happened.
1649 ///
1650 /// The whole **driver** and not just its
1651 /// [`TransformRuntime`](crate::transform_session::TransformRuntime),
1652 /// because dropping the session is only a third of what ending a gesture
1653 /// means. `TransformRuntime::abort` did that third; the edge auto-pan went
1654 /// on tweening (for up to thirty seconds, with no input and with
1655 /// `edge_panning` still `true`), and `on_end` — documented as "fired once
1656 /// when a gesture finishes, committed **or cancelled**" — never fired, so
1657 /// an app that opens an overlay on `on_start` leaked it every time the OS
1658 /// revoked the contact. One cancel path, reached from here too.
1659 transform: Option<super::view::transform::TransformDriver>,
1660}
1661
1662impl DragUnwind {
1663 /// Drop everything, reporting whether anything was actually there.
1664 ///
1665 /// `drag_target` is the one that used to survive a cancel and matters most:
1666 /// `paint` translates the grabbed item — and its whole drag group — by
1667 /// `current - anchor` on every frame for as long as it is set, so leaving it
1668 /// behind drew the item at the dragged position *permanently*, while the
1669 /// model went on saying it had never moved. `pending_item_move` is the
1670 /// commit that would otherwise have landed on the next rebuild; a cancel is
1671 /// terminal, so no `Ended` can have queued it and it belongs to the gesture
1672 /// that was just taken away.
1673 pub(super) fn clear(&self, ctx: &mut teksilo_core::widget::EventContext) -> bool {
1674 // Bitwise OR, not `||`: every one of these has to run.
1675 self.drag_target.take().is_some()
1676 | self.pending_item_move.take().is_some()
1677 | self.marquee.take().is_some()
1678 | self.pending_marquee_commit.replace(None).is_some()
1679 | self.port_drag.replace(None).is_some()
1680 | self.item_snap.replace(None).is_some()
1681 | self
1682 .transform
1683 .as_ref()
1684 .is_some_and(|driver| driver.cancel(ctx))
1685 }
1686}
1687
1688/// Everything the **hover episode** opened, and the one place it is closed.
1689///
1690/// The view raises three things while a hovering pointer is inside it, and each
1691/// one outlives the dispatch that raised it: an item's `on_hover(true)`, the
1692/// cursor (a `set_cursor` override, which by contract stands until the same
1693/// handler takes it back), and the item tooltip — on either of its two paths,
1694/// armed-and-counting-down or already shown. Every one of them is re-decided
1695/// only by another hovering move *inside* the view, so a pointer that goes away
1696/// takes the deciding move with it.
1697///
1698/// It is shared rather than written twice because two different events end an
1699/// episode and they must not drift: the pointer leaving (`on_hover(false)` on
1700/// the view's own node) and the pointer being revoked (`on_pointer_cancel`).
1701/// The cancel arm unwinds this **and** [`DragUnwind`]; a departure deliberately
1702/// does not, which is the one place the two legitimately differ — see the call
1703/// sites.
1704pub(super) struct HoverUnwind {
1705 // Private to `view`, which is enough: the only consumer is
1706 // `view::gestures_impl`, a child module, and `HandlerSnapshotEntry` is
1707 // itself module-private — re-exporting the field at `pub(super)` would
1708 // leak a name the rest of the crate cannot spell.
1709 hovered_item: Rc<Cell<Option<crate::item::ItemId>>>,
1710 handler_snapshot: Rc<RefCell<Vec<HandlerSnapshotEntry>>>,
1711 cursor_pos: Rc<Cell<Option<Point>>>,
1712 /// The view's tooltip body, the key both retraction calls are made on.
1713 tooltip_content_id: WidgetId,
1714}
1715
1716impl HoverUnwind {
1717 /// Close the episode: unhover the item, retract the tooltip on both paths,
1718 /// put the cursor down, forget where the pointer was.
1719 ///
1720 /// The two tooltip calls are both unconditional and neither substitutes for
1721 /// the other: `cancel_delayed_overlay` drops a show that is still queued on
1722 /// the tree (which would otherwise surface *after* the pointer had gone,
1723 /// anchored to a view it is no longer over), and `dismiss_overlay_by_content`
1724 /// takes down one already on the stack. A hover tip and a held tip each
1725 /// reach exactly one of them.
1726 ///
1727 /// The cursor is set to [`CursorIcon::Default`](teksilo_core::widget::CursorIcon::Default)
1728 /// rather than released. A
1729 /// release restores what the *current* hover chain declared, and at this
1730 /// moment that chain is the one being torn down — the destination's own
1731 /// `PointerEnter` has not run yet. Writing `Default` is the same answer the
1732 /// framework's own leave arm gives a node that declared a cursor, and the
1733 /// destination's declaration lands after ours and wins.
1734 pub(super) fn clear(&self, ctx: &mut teksilo_core::widget::EventContext) {
1735 if let Some(prev) = self.hovered_item.take()
1736 && let Some(entry) = self.handler_snapshot.borrow().iter().find(|e| e.id == prev)
1737 && let Some(h) = entry.handlers.as_deref()
1738 && let Some(cb) = h.on_hover.as_ref()
1739 {
1740 cb(false, ctx);
1741 }
1742 ctx.cancel_delayed_overlay(self.tooltip_content_id);
1743 ctx.dismiss_overlay_by_content(self.tooltip_content_id);
1744 self.cursor_pos.set(None);
1745 ctx.set_cursor(teksilo_core::widget::CursorIcon::Default);
1746 }
1747}
1748
1749impl SceneView {
1750 /// This view's [`DragUnwind`] handles, cloned for a handler closure.
1751 pub(super) fn drag_unwind(&self) -> DragUnwind {
1752 DragUnwind {
1753 drag_target: self.drag_target.clone(),
1754 pending_item_move: self.pending_item_move.clone(),
1755 marquee: self.marquee.clone(),
1756 pending_marquee_commit: self.pending_marquee_commit.clone(),
1757 port_drag: self.port_drag.clone(),
1758 item_snap: self.item_snap.clone(),
1759 transform: self.transform_driver(),
1760 }
1761 }
1762
1763 /// This view's [`HoverUnwind`] handles, cloned for a handler closure.
1764 pub(super) fn hover_unwind(&self, tooltip_content_id: WidgetId) -> HoverUnwind {
1765 HoverUnwind {
1766 hovered_item: self.hovered_item.clone(),
1767 handler_snapshot: self.handler_snapshot.clone(),
1768 cursor_pos: self.cursor_pos.clone(),
1769 tooltip_content_id,
1770 }
1771 }
1772}
1773
1774/// The state the `Over`-band veto reads, borrowed rather than cloned.
1775///
1776/// Both callers hold these already: the `Widget` impl has `&self`, and the drag
1777/// closure captured its own clones at build time. Passing borrows keeps the
1778/// arena's per-child call free of refcount traffic.
1779struct VetoState<'a> {
1780 over_claimants: &'a Cell<bool>,
1781 veto_memo: &'a Cell<Option<(u64, [f32; 6], Point, Option<PaintKey>)>>,
1782 veto_scans: &'a Cell<u64>,
1783 snapshot_generation: &'a Cell<u64>,
1784 handler_snapshot: &'a RefCell<Vec<HandlerSnapshotEntry>>,
1785 view_transform: &'a Signal<Transform2D>,
1786}
1787
1788/// The key of the topmost entry **above `floor`** whose shape contains
1789/// `scene_pt`, if that entry **claims the press** — otherwise `None`.
1790///
1791/// This is the whole occlusion rule in one query, and it is deliberately "the
1792/// topmost entry above the floor, *if* it claims" rather than "the topmost
1793/// claimant above the floor":
1794///
1795/// * it mirrors `hit_handler_item`, which resolves the topmost **entry** and
1796/// only then looks for handlers — so the veto can never disagree with the
1797/// dispatch that follows it;
1798/// * a decorative item painted over an interactive one blocks it, exactly as it
1799/// does today, instead of being stepped over.
1800///
1801/// `floor` is what makes it answerable for
1802/// [`Interleaved`](crate::SceneLayer::Interleaved). The `Over` band is above
1803/// *every* card, so a rank sufficed; an interleaved entry is above some cards
1804/// and below others, and only a whole [`PaintKey`] can say which. The per-child
1805/// caller ([`SceneView::accepts_child_hit`]) passes that child's own key, so a
1806/// card painted over an interleaved claimant keeps its press and one painted
1807/// under it yields — which is the same rule the eye applies.
1808///
1809/// # Why the floor is applied here and not inside the scan
1810///
1811/// The scan is [`topmost_press_claimant`], which takes no floor, and the floor
1812/// is one `PaintKey` compare against its answer. The two forms are the same
1813/// answer because the snapshot is sorted by key descending: the first entry the
1814/// scan meets is the topmost containing one *overall*, so
1815///
1816/// * if that entry's key is at or above `floor`, it is also the first one a
1817/// floored scan would have met — same entry, same verdict;
1818/// * if it is below `floor`, then every containing entry is below `floor`
1819/// (they are all below *it*), and a floored scan finds nothing — which is the
1820/// `None` the compare produces.
1821///
1822/// The slop pass cannot break the equivalence either: it is miss-only and this
1823/// call site hands it the mouse profile, whose slop radius is zero, so it never
1824/// reports anything (see the `GrabSlop` construction in
1825/// [`topmost_press_claimant`]).
1826///
1827/// This split is not a micro-optimisation. The floor **differs on every call** —
1828/// it is the asking child's own key — so folding it into the memo key makes the
1829/// memo miss once per child, which is exactly the rescan the memo exists to
1830/// prevent. Keeping the scan floor-free is what makes one hit test cost one
1831/// scan at any card count.
1832fn press_claimant_above(
1833 state: VetoState<'_>,
1834 floor: PaintKey,
1835 scene_pt: Point,
1836) -> Option<PaintKey> {
1837 topmost_press_claimant(state, scene_pt).filter(|top| *top >= floor)
1838}
1839
1840/// The key of the topmost entry whose shape contains `scene_pt`, if that entry
1841/// **claims the press** — otherwise `None`. Memoised for one hit walk.
1842///
1843/// Floor-free by design: see [`press_claimant_above`], which applies a caller's
1844/// floor to this answer.
1845///
1846/// Reads only the per-layout `handler_snapshot` — never the
1847/// [`SceneModel`]. A hit test runs inside the router, where
1848/// an `ItemChange` observer may already hold the model's `RefCell`, and
1849/// re-entering it there is a panic.
1850fn topmost_press_claimant(state: VetoState<'_>, scene_pt: Point) -> Option<PaintKey> {
1851 if !state.over_claimants.get() {
1852 return None;
1853 }
1854 let view_xform = state.view_transform.get();
1855 // The second space. A screen-anchored (`IGNORES_TRANSFORMATIONS`) entry —
1856 // the flag whose own doc names "annotation pins … fixed-pixel-size badges
1857 // over moving content", i.e. the most likely real `Over` claimant after a
1858 // halo — hit-tests where it is *drawn*, not where its scene anchor is. The
1859 // view owns the transform, so both spaces are recoverable from the one
1860 // point the arena hands over.
1861 let screen_pt = view_xform.apply_point(scene_pt);
1862 let generation = state.snapshot_generation.get();
1863 if let Some((memo_gen, memo_xform, memo_pt, answer)) = state.veto_memo.get()
1864 && memo_gen == generation
1865 && memo_xform == view_xform.m
1866 && memo_pt == scene_pt
1867 {
1868 return answer;
1869 }
1870 state.veto_scans.set(state.veto_scans.get().wrapping_add(1));
1871 let snapshot = state.handler_snapshot.borrow();
1872 let answer = hit_handler_index(
1873 &snapshot,
1874 screen_pt,
1875 scene_pt,
1876 view_xform,
1877 // The veto is a question about what is painted where, not about what a
1878 // finger can *reach*: widening it would let an `Over` claimant punch a
1879 // hole in a card several device pixels away from anything the user can
1880 // see. The slop pass is unreachable from here anyway — the view's own
1881 // bounds always accept, so a hit test inside it is never the miss that
1882 // pass exists to re-attribute.
1883 GrabSlop::new(
1884 teksilo_tokens::InputTokens::default(),
1885 teksilo_tokens::PointerKind::Mouse,
1886 ),
1887 // "Admit everything": the answer is floor-free, and a caller's floor is
1888 // applied to it afterwards.
1889 PaintKey::bottom(),
1890 )
1891 .filter(|index| snapshot[*index].claims_press)
1892 .map(|index| snapshot[index].key);
1893 drop(snapshot);
1894 state
1895 .veto_memo
1896 .set(Some((generation, view_xform.m, scene_pt, answer)));
1897 answer
1898}
1899
1900impl SceneView {
1901 /// A child's paint key, as of the last build.
1902 ///
1903 /// [`PaintKey::rank_floor(RANK_WIDGET)`](PaintKey::rank_floor) — the bottom
1904 /// of the card band — for a child this view did not place, and for the
1905 /// window between a child being added and the next build publishing its
1906 /// key. That is the permissive answer, and the same one every card got
1907 /// before keys were compared per child.
1908 pub(super) fn child_paint_key(&self, child: WidgetId) -> PaintKey {
1909 self.key_probes.set(self.key_probes.get().wrapping_add(1));
1910 self.child_keys
1911 .borrow()
1912 .get(&child)
1913 .copied()
1914 .unwrap_or_else(|| PaintKey::rank_floor(crate::pick::RANK_WIDGET))
1915 }
1916
1917 /// [`topmost_press_claimant`] against this view's own state — the
1918 /// floor-free form, for a caller that has a floor it would rather not
1919 /// compute unless there is an answer to compare it with.
1920 ///
1921 /// [`accepts_child_hit`](SceneView::accepts_child_hit) is that caller: its
1922 /// floor is a `HashMap` lookup per child, and in a scene with no press
1923 /// claimant (or with the pointer nowhere near one) there is nothing for it
1924 /// to be compared against.
1925 pub(super) fn topmost_press_claimant(&self, scene_pt: Point) -> Option<PaintKey> {
1926 topmost_press_claimant(self.veto_state(), scene_pt)
1927 }
1928
1929 fn veto_state(&self) -> VetoState<'_> {
1930 VetoState {
1931 over_claimants: &self.over_claimants,
1932 veto_memo: &self.veto_memo,
1933 veto_scans: &self.veto_scans,
1934 snapshot_generation: &self.snapshot_generation,
1935 handler_snapshot: &self.handler_snapshot,
1936 view_transform: &self.view_transform_signal,
1937 }
1938 }
1939
1940 /// How many times this view has walked its handler snapshot to answer the
1941 /// above-a-card veto, since it was created.
1942 ///
1943 /// The veto's memo is asked once per hit-tested child and answers from one
1944 /// scan; this is the number that says so. A hit test over *n* overlapping
1945 /// cards must advance it by **one**, not by *n* — the regression the
1946 /// `veto_scaling_probe` test gates, and one that is invisible to every
1947 /// other observable the crate publishes (the verdicts are identical either
1948 /// way; only the cost moves).
1949 #[doc(hidden)]
1950 pub fn veto_snapshot_scans(&self) -> u64 {
1951 self.veto_scans.get()
1952 }
1953
1954 /// How many times the veto has looked a child's [`PaintKey`] up.
1955 ///
1956 /// The companion to [`veto_snapshot_scans`](Self::veto_snapshot_scans), and
1957 /// necessary because the two halves of the veto's cost fail independently:
1958 /// a memo keyed on something that differs per child makes the *scan* count
1959 /// follow the card count, while evaluating the child's key before the
1960 /// claimant query can decline it makes *this* one follow it. Each is
1961 /// invisible to the other's test, and neither changes a verdict.
1962 ///
1963 /// A scene with no press claimant under the pointer must leave this at
1964 /// zero however many children the arena asks about.
1965 #[doc(hidden)]
1966 pub fn veto_key_probes(&self) -> u64 {
1967 self.key_probes.get()
1968 }
1969
1970 /// The items a pointer drag of `grabbed` carries.
1971 ///
1972 /// A drag of an item that is part of a multi-selection moves the **whole
1973 /// selection**, the same contract `Alt+Arrow` keeps — a corkboard user who
1974 /// rubber-bands five cards and then drags one expects five to move, and the
1975 /// keyboard alternative has to agree with the pointer or WCAG 2.5.7's
1976 /// "dragging movements" equivalence is a fiction.
1977 ///
1978 /// A selected item that is a **descendant** of another selected item is
1979 /// dropped: its `local_pos` is relative to that ancestor, so moving both
1980 /// would translate it twice.
1981 ///
1982 /// Derived rather than snapshotted at grab time, so the live paint feedback
1983 /// and the commit cannot disagree about which items are moving.
1984 pub(super) fn drag_group(&self, grabbed: ItemId) -> Vec<ItemId> {
1985 drag_group_of(&self.model, &self.selection, grabbed)
1986 }
1987
1988 /// The paint-order floor for a dispatch that is running on this view.
1989 ///
1990 /// A [`RANK_OVER`] rank floor when the arena gave this pointer to one of
1991 /// our heavyweight children (we are previewing, not targeted) — only the
1992 /// `Over` band outranks **every** card, so everything below it must stand
1993 /// aside and let the card have the event.
1994 /// [`PaintKey::bottom`] when we are the target, which is exactly the case
1995 /// where no card won: the arena has already applied `hit_shape`,
1996 /// `hit_transparent`, `event_pass_through` **and**
1997 /// [`accepts_child_hit`](SceneView::accepts_child_hit) — so an
1998 /// [`Interleaved`](crate::SceneLayer::Interleaved) claimant that vetoed the
1999 /// cards beneath it has already produced this branch, and reading the
2000 /// verdict here is what stops the two pickers re-deriving it differently.
2001 ///
2002 /// The coarse `RANK_OVER` floor in the first branch is deliberate and
2003 /// slightly conservative: an interleaved entry that is above the winning
2004 /// card but does **not** claim the press is treated, for hover and cursor,
2005 /// the way an `Under` entry under that card is. It could not have won the
2006 /// press (it does not claim), and narrowing the floor to the winning card's
2007 /// own key would mean resolving *which* card won from a `dispatch_target`
2008 /// that may be one of its descendants.
2009 ///
2010 /// A context with no verdict at all (a gesture timer, a hand-built
2011 /// `EventContext` in a test) is treated as "we are the target", which is
2012 /// the permissive, pre-existing behaviour.
2013 fn dispatch_floor(ctx: &teksilo_core::widget::EventContext, self_id: WidgetId) -> PaintKey {
2014 match ctx.dispatch_target() {
2015 Some(target) if target != self_id => PaintKey::rank_floor(RANK_OVER),
2016 _ => PaintKey::bottom(),
2017 }
2018 }
2019}
2020
2021mod a11y_impl;
2022mod build_impl;
2023mod builder_impl;
2024mod camera_impl;
2025mod gestures_impl;
2026mod hit_snapshot;
2027mod layout_impl;
2028mod magnetism;
2029mod paint_impl;
2030pub(crate) mod paint_node;
2031mod transform;
2032mod widget_trait;
2033
2034/// Union an iterator of axis-aligned rectangles into a single
2035/// bounding rectangle. Returns `None` if the iterator is empty.
2036fn union_rects(mut rects: impl Iterator<Item = Rect>) -> Option<Rect> {
2037 let first = rects.next()?;
2038 let mut min_x = first.x;
2039 let mut min_y = first.y;
2040 let mut max_x = first.right();
2041 let mut max_y = first.bottom();
2042 for r in rects {
2043 if r.x < min_x {
2044 min_x = r.x;
2045 }
2046 if r.y < min_y {
2047 min_y = r.y;
2048 }
2049 if r.right() > max_x {
2050 max_x = r.right();
2051 }
2052 if r.bottom() > max_y {
2053 max_y = r.bottom();
2054 }
2055 }
2056 Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
2057}
2058
2059#[cfg(test)]
2060mod tests;