Skip to main content

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;