Skip to main content

rosace_widgets/tree/
render_tree.rs

1//! Persistent render tree — the single owner of per-node retained state (D091).
2//!
3//! Every widget position gets a node. During paint a widget *declares* its
4//! interactive regions and attachments onto its node; the frame pipeline then
5//! derives hit-test order, scroll routing, the overlay stack, focus order, and
6//! transform layers from the tree. Nothing is re-emitted per frame through
7//! side channels, so state survives cache-hit frames by construction.
8//!
9//! # Identity
10//! A node's identity is its position within its parent's paint order. This is
11//! safe because widget paint recursion always descends fully once entered —
12//! only the element walker may skip a subtree (picture cache hit), and it
13//! consumes the child slot *without* resetting it, keeping siblings aligned
14//! and the skipped subtree's state intact.
15//!
16//! The one place positional identity is NOT safe: [`ScreenTransitionView`]
17//! (`screen_transition_view.rs`), where the exact same tree position holds a
18//! completely different, unrelated screen's subtree every time navigation
19//! changes. Positional reuse there silently aliased one screen's scroll
20//! offset/animation state onto the next screen that happened to land on the
21//! same `NodeId` (2026-08-01, real trackpad + navigation testing). Its child
22//! is addressed through [`RenderTree::keyed_slot`] instead of the ordinary
23//! [`RenderTree::slot`] — a small, explicitly-keyed side table scoped to
24//! that one call site, not a general per-widget keying system.
25//!
26//! [`ScreenTransitionView`]: super::ScreenTransitionView
27
28use std::collections::HashMap;
29use std::sync::Arc;
30
31use rosace_core::types::{Rect, Size};
32use rosace_layout::Constraints;
33use rosace_render::Picture;
34
35use super::overlay::OverlayEntry;
36use super::TransformLayerEntry;
37
38pub type NodeId = usize;
39
40/// A resolved hit/scroll handler — invoked with the event's (x, y) in
41/// window-space logical pixels.
42pub type HitHandler = Arc<dyn Fn(f32, f32) + Send + Sync>;
43
44/// A nested-scroll chain link (D-NESTED-SCROLL, 2026-08-02) — takes a
45/// `(dx, dy)` DELTA (not an absolute position, unlike [`HitHandler`]) and
46/// returns whether it actually moved: `true` if it consumed some or all of
47/// the delta, `false` if it's already fully exhausted in that exact
48/// direction (hard-clamped, or stretched to `Bounce`'s own overscroll
49/// limit) and had NO effect. A gesture starting inside nested scrollable
50/// regions (an inner `ScrollView`/carousel sitting inside an outer one, or
51/// a plain-hit `Button`/`ListTile` sitting inside any `ScrollView`) tries
52/// the innermost link first each move and only offers the SAME delta to
53/// the next link outward once the current one declines — so scrolling
54/// naturally "hands off" to an enclosing scrollable ancestor exactly when,
55/// and only when, the inner one has nothing left to give.
56pub type ScrollHandler = Arc<dyn Fn(f32, f32) -> bool + Send + Sync>;
57
58/// A click callback with its hit rect in window-space logical pixels.
59pub type HitRegion = (Rect, Arc<dyn Fn() + Send + Sync>);
60/// A positional click callback — receives the click point in window-space
61/// logical pixels (sliders, color pickers, canvases).
62pub type HitRegionAt = (Rect, Arc<dyn Fn(f32, f32) + Send + Sync>);
63
64/// Which wheel/trackpad axes a scroll region can consume. Routing prefers
65/// the innermost region that handles the DOMINANT axis of a delta — an
66/// x-only carousel must not swallow a vertical page scroll.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct ScrollAxes {
69    pub x: bool,
70    pub y: bool,
71}
72
73impl ScrollAxes {
74    pub const BOTH: ScrollAxes = ScrollAxes { x: true, y: true };
75    pub const X: ScrollAxes = ScrollAxes { x: true, y: false };
76    pub const Y: ScrollAxes = ScrollAxes { x: false, y: true };
77}
78
79/// A `(delta_x, delta_y)` scroll callback with its viewport rect and the
80/// axes it handles.
81pub type ScrollRegion = (Rect, ScrollAxes, Arc<dyn Fn(f32, f32) + Send + Sync>);
82
83/// A registered pinch-to-zoom region (`InteractiveViewer`, Phase 32) — the
84/// callback receives the gesture's `delta` (winit's `PinchGesture::delta`:
85/// positive = magnify, negative = shrink; NOT a multiplier, an increment —
86/// callers typically do `zoom *= 1.0 + delta`).
87pub type ZoomRegion = (Rect, Arc<dyn Fn(f32) + Send + Sync>);
88
89/// One render-tree node. Declared data is cleared when the node is repainted
90/// (`begin`) and persists untouched otherwise.
91#[derive(Default)]
92pub struct TreeNode {
93    pub children: Vec<NodeId>,
94    /// Child slot cursor for the current paint of this node.
95    cursor: usize,
96    /// Children addressed by [`RenderTree::keyed_slot`] instead of position
97    /// — see the module doc's "Identity" section. Only [`ScreenTransitionView`]
98    /// (`screen_transition_view.rs`) uses this; every other widget's children
99    /// live in `children`/`cursor` above, untouched.
100    ///
101    /// [`ScreenTransitionView`]: super::ScreenTransitionView
102    pub keyed_children: HashMap<u64, NodeId>,
103    /// True if this node was begun (repainted) in the current frame.
104    begun: bool,
105
106    // ── Declared per-paint data (D091) ────────────────────────────────────
107    pub hits:       Vec<HitRegion>,
108    pub hits_at:    Vec<HitRegionAt>,
109    /// Nested-scroll chain links declared this node (D-NESTED-SCROLL) —
110    /// see [`ScrollHandler`]'s own doc. Separate from `hits_at`: a plain
111    /// slider-style positional drag always fully "consumes" a gesture by
112    /// definition, but a `ScrollView`'s pan needs to report exhaustion so
113    /// an enclosing scrollable ancestor gets a turn.
114    pub nested_scrolls: Vec<(Rect, ScrollHandler)>,
115    pub scrolls:    Vec<ScrollRegion>,
116    pub zooms:      Vec<ZoomRegion>,
117    pub focus:      Vec<rosace_a11y::FocusNode>,
118    pub overlays:   Vec<OverlayEntry>,
119    pub transforms: Vec<TransformLayerEntry>,
120    pub semantics:  Vec<super::Semantics>,
121
122    /// Editable text content declared this paint (D112/Phase 28 Step 1) —
123    /// current value, rect, and the `on_change` callback. Cleared each
124    /// repaint like `hits`/`scrolls`; the engine's key/click dispatch
125    /// reads it fresh rather than caching, since a rebuild may swap in a
126    /// different `on_change` closure.
127    pub editable: Option<super::text_edit::EditableDecl>,
128
129    // ── Persistent per-node state (NOT cleared on repaint) ───────────────
130    /// The node's implicit scroll position (D101) — created lazily by the
131    /// first scrollable painted at this position, survives rebuilds like
132    /// Flutter's ScrollPosition.
133    pub scroll_ctrl: Option<rosace_scroll::ScrollController>,
134    /// A persistent eased scalar (0..1) for toggle transitions — advanced by
135    /// PaintCtx::animate_to. `None` until first observed (then snaps).
136    pub anim: Option<f32>,
137    /// Multiple independent persistent eased scalars for a widget that needs
138    /// to animate more than one value at once (e.g. a Switch's position AND
139    /// its hover/press state-layer) — advanced by `PaintCtx::animate_channel`,
140    /// indexed by an explicit channel id. Each entry is `None` until first
141    /// observed (then snaps), exactly like `anim`. Grows on demand; persists
142    /// across repaints and cache-hit frames like the other retained state.
143    pub anim_channels: Vec<Option<f32>>,
144    /// This node's [`rosace_a11y::FocusNode`] (D112/Phase 28 Step 1) —
145    /// created lazily by [`super::PaintCtx::focus_node`], survives
146    /// rebuilds like `scroll_ctrl` above.
147    pub focus_node: Option<rosace_a11y::FocusNode>,
148    /// Persistent cursor/selection state for an editable node (D091/D112)
149    /// — NOT cleared on repaint, so the caret survives a rebuild with the
150    /// same displayed value.
151    pub text_edit: super::text_edit::TextEditState,
152
153    // ── Picture cache (Phase 20 unification — was the flat RenderNode) ───
154    /// Widget type name at this position; a mismatch resets the caches.
155    pub tag: &'static str,
156    /// Constraints used for the last successful layout pass.
157    pub last_constraints: Option<Constraints>,
158    /// Size returned by the last layout pass.
159    pub cached_size: Option<Size>,
160    /// Display list from the last paint pass.
161    pub cached_picture: Option<Arc<Picture>>,
162    /// World-space rect of the last paint (also the damage extent).
163    pub cached_rect: Option<Rect>,
164    /// When true, the subtree must re-layout/re-paint this frame.
165    pub paint_dirty: bool,
166
167    // ── Interaction state (dispatcher-owned) ─────────────────────────────
168    /// True while the cursor is over this node's hit/hover region.
169    pub hovered: bool,
170    /// True from MouseDown until MouseUp on this node — drives press/tap
171    /// feedback (D108/Phase 26 Step 1), same dispatcher-owned shape as
172    /// `hovered`.
173    pub pressed: bool,
174    /// Pointer interception: 1 = ignore (subtree transparent to hits),
175    /// 2 = absorb (consume everything in rect). Declared per paint.
176    pub pointer_mode: u8,
177    /// Hover-only regions (tooltips) — participate in hover_test but not
178    /// in click dispatch.
179    pub hover_regions: Vec<Rect>,
180    /// Long-press callbacks with their rects.
181    pub long_hits: Vec<HitRegion>,
182}
183
184/// Arena-allocated persistent render tree. Node 0 is always the root.
185pub struct RenderTree {
186    nodes: Vec<TreeNode>,
187    /// Nodes begun this frame — finalized (children truncated) at frame end.
188    begun_this_frame: Vec<NodeId>,
189}
190
191impl RenderTree {
192    pub fn new() -> Self {
193        Self {
194            nodes: vec![TreeNode::default()],
195            begun_this_frame: Vec::new(),
196        }
197    }
198
199    pub const ROOT: NodeId = 0;
200
201    /// Start a new frame and begin the root. Must be called before painting.
202    pub fn start_frame(&mut self) {
203        for &id in &self.begun_this_frame {
204            self.nodes[id].begun = false;
205        }
206        self.begun_this_frame.clear();
207        self.begin(Self::ROOT);
208    }
209
210    /// Reset a node for a fresh paint: clears its declarations (the picture
211    /// cache fields persist — the walker manages those explicitly).
212    pub fn reset(&mut self, node: NodeId) {
213        self.begin(node);
214    }
215
216    /// Begin (re)painting `node`: clear declared data, reset the child cursor.
217    fn begin(&mut self, node: NodeId) {
218        let n = &mut self.nodes[node];
219        n.cursor = 0;
220        n.begun = true;
221        n.hits.clear();
222        n.hits_at.clear();
223        n.nested_scrolls.clear();
224        n.scrolls.clear();
225        n.zooms.clear();
226        n.focus.clear();
227        n.overlays.clear();
228        n.transforms.clear();
229        n.semantics.clear();
230        n.pointer_mode = 0;
231        n.hover_regions.clear();
232        n.long_hits.clear();
233        n.editable = None;
234        self.begun_this_frame.push(node);
235    }
236
237    /// Consume the next child slot of `parent`.
238    ///
239    /// `reset == true` (normal paint descent): the child is begun — its
240    /// declared data is cleared for re-declaration.
241    /// `reset == false` (cache-hit replay): the slot is consumed so siblings
242    /// stay positionally aligned, but the child subtree keeps all its state.
243    pub fn slot(&mut self, parent: NodeId, reset: bool) -> NodeId {
244        let cursor = self.nodes[parent].cursor;
245        self.nodes[parent].cursor += 1;
246
247        let child = if cursor < self.nodes[parent].children.len() {
248            self.nodes[parent].children[cursor]
249        } else {
250            let id = self.nodes.len();
251            self.nodes.push(TreeNode::default());
252            self.nodes[parent].children.push(id);
253            id
254        };
255
256        if reset {
257            self.begin(child);
258        }
259        child
260    }
261
262    /// Like [`Self::slot`], but the returned `NodeId` is resolved by an
263    /// explicit stable `key` instead of "whatever was previously at this
264    /// position" — see the module doc's "Identity" section. Reusing an
265    /// existing key's node preserves ALL its sticky state (`scroll_ctrl`,
266    /// `anim_channels`, hover/press, and everything underneath it in the
267    /// subtree, however deep) exactly like an ordinary same-position
268    /// repaint does; a new key gets a brand-new node with empty
269    /// `children`/`keyed_children`, so nothing nested under it — however
270    /// many `ScrollView`s/`Tabs`/`TextArea`s it contains — can possibly
271    /// alias whatever a DIFFERENT key's subtree left behind.
272    ///
273    /// The resolved node is ALSO written into `parent`'s ordinary
274    /// `children`/`cursor` slot, same as `slot()` — the key only changes
275    /// which `NodeId` ends up at that position, not how it's found
276    /// afterward. This matters: hit-testing, hover, semantics/accessibility,
277    /// and the picture-cache walk all traverse `children`, not
278    /// `keyed_children` — a node reachable ONLY through the keyed map would
279    /// be invisible to all of them (found via a real test failure —
280    /// `semantic_labels` came back empty for a screen reached this way).
281    pub fn keyed_slot(&mut self, parent: NodeId, key: u64) -> NodeId {
282        let child = match self.nodes[parent].keyed_children.get(&key) {
283            Some(&id) => id,
284            None => {
285                let id = self.nodes.len();
286                self.nodes.push(TreeNode::default());
287                self.nodes[parent].keyed_children.insert(key, id);
288                id
289            }
290        };
291
292        let cursor = self.nodes[parent].cursor;
293        self.nodes[parent].cursor += 1;
294        if cursor < self.nodes[parent].children.len() {
295            self.nodes[parent].children[cursor] = child;
296        } else {
297            self.nodes[parent].children.push(child);
298        }
299
300        self.begin(child);
301        child
302    }
303
304    /// Drop any of `parent`'s keyed children whose key is no longer in
305    /// `valid_keys` — called once per frame by `ScreenTransitionView` with
306    /// the navigation stack's current keys, so a screen's cached subtree
307    /// (scroll position, animation state, everything) is released once
308    /// it's actually been popped, not retained forever. The dropped node's
309    /// arena slot itself isn't reclaimed (this arena never frees — same
310    /// tradeoff `slot()`'s positional children already have for any widget
311    /// that stops being painted), only the reference to it.
312    pub fn prune_keyed_children(&mut self, parent: NodeId, valid_keys: &[u64]) {
313        self.nodes[parent].keyed_children.retain(|k, _| valid_keys.contains(k));
314    }
315
316    /// End of frame: drop unused child slots of every node repainted this
317    /// frame, so removed widgets cannot leave ghost hit regions behind.
318    pub fn finalize(&mut self) {
319        for i in 0..self.begun_this_frame.len() {
320            let id = self.begun_this_frame[i];
321            let cursor = self.nodes[id].cursor;
322            self.nodes[id].children.truncate(cursor);
323        }
324    }
325
326    pub fn node_mut(&mut self, id: NodeId) -> &mut TreeNode {
327        &mut self.nodes[id]
328    }
329
330    pub fn node(&self, id: NodeId) -> &TreeNode {
331        &self.nodes[id]
332    }
333
334    /// Every node in the arena, for callers that need to scan rather than
335    /// look up a specific id (e.g. tests asserting some node reached a
336    /// given interaction state without knowing its id in advance).
337    pub fn nodes_iter(&self) -> impl Iterator<Item = &TreeNode> {
338        self.nodes.iter()
339    }
340
341    /// Same as [`Self::nodes_iter`], paired with each node's [`NodeId`] —
342    /// needed by callers that must look the node back up for a second,
343    /// mutable pass (D116's `EditController` draining: the engine collects
344    /// `(NodeId, controller, ops)` immutably first, since it can't mutate
345    /// the tree while iterating it).
346    pub fn nodes_indexed(&self) -> impl Iterator<Item = (NodeId, &TreeNode)> {
347        self.nodes.iter().enumerate()
348    }
349
350    // ── Derivations (D091/D092) ───────────────────────────────────────────
351
352    /// Hit-test walk: children before own regions, later siblings first —
353    /// paint order is z-order, so the topmost match wins structurally (D092).
354    /// Returns the topmost hit callback, whether it is POSITIONAL —
355    /// positional hits become the active drag grab (streamed MouseMove
356    /// positions until release); plain hits fire once — and, when the
357    /// winner is a plain hit, so a touch/mouse gesture that starts on a
358    /// plain-hit child (Button, ListTile, …) sitting inside e.g. a
359    /// `ScrollView` can still fall back to dragging that ancestor once
360    /// movement shows it's a scroll, not a tap (2026-08-02, real Android
361    /// touch testing — without this a plain-hit child sitting anywhere in
362    /// a scrollable page permanently shadowed the ScrollView's own drag
363    /// region, so touch-drag scrolling silently did nothing on any page
364    /// with interactive content — desktop was unaffected since
365    /// wheel/trackpad scroll is a wholly separate `InputEvent::Scroll`
366    /// path).
367    ///
368    /// The chain is the SECOND return value, always present — collected
369    /// independently of what the leaf hit resolves to (`None`, a plain
370    /// tap, or even a positional widget like a `Slider`), so touching
371    /// blank scrollable space directly (no leaf hit at all) still yields
372    /// a usable chain even though the first value is `None`.
373    pub fn hit_test(&self, x: f32, y: f32) -> (Option<(HitHandler, bool)>, Vec<ScrollHandler>) {
374        let mut chain = Vec::new();
375        let leaf = self.hit_test_node(Self::ROOT, x, y, &mut chain);
376        (leaf, chain)
377    }
378
379    /// Map screen coords into the content space of a node hosting a placed
380    /// scroll layer (D090). A transform node's children declare their hit
381    /// regions at content-local coords `(0,0)`-based, but the content is drawn
382    /// at the viewport scrolled by the live channel offset. Returns the coords
383    /// to descend into children with, and `true` when the point falls OUTSIDE
384    /// the viewport (children receive nothing — content is clipped to it).
385    /// Non-transform nodes pass coords through unchanged.
386    fn child_coords(&self, n: &TreeNode, id: NodeId, x: f32, y: f32) -> (f32, f32, bool) {
387        let Some(entry) = n.transforms.first() else { return (x, y, false); };
388        let vp = entry.viewport_rect;
389        if !contains(&vp, x, y) {
390            return (x, y, true);
391        }
392        let off = rosace_state::scroll_offset(id as u64);
393        // `offset` lives in content-native (unzoomed) pixels — a screen
394        // delta maps to a SMALLER content delta at higher zoom (the view is
395        // magnified), matching InteractiveViewer's pan-by-drag divisor.
396        let z = entry.zoom;
397        ((x - vp.origin.x) / z + off[0], (y - vp.origin.y) / z + off[1], false)
398    }
399
400    /// Walks the SAME recursion `hit_test`/`nested_scroll_chain` both need,
401    /// so the two stay perfectly in sync by construction (one traversal,
402    /// not two): returns the leaf hit exactly like the old two-element
403    /// version did, and — independently of what that leaf is, or even
404    /// whether one was found at all — pushes every node's own
405    /// `nested_scrolls` entry covering `(x, y)` onto `chain` as the
406    /// recursion unwinds, innermost first.
407    fn hit_test_node(&self, id: NodeId, x: f32, y: f32, chain: &mut Vec<ScrollHandler>) -> Option<(HitHandler, bool)> {
408        let n = &self.nodes[id];
409        // Pointer interceptors (IgnorePointer / AbsorbPointer widgets):
410        // 1 = subtree transparent to hits; 2 = consume everything in rect.
411        if n.pointer_mode == 1 {
412            return None;
413        }
414        if n.pointer_mode == 2 {
415            if let Some(r) = &n.cached_rect {
416                if contains(r, x, y) {
417                    return Some((Arc::new(|_, _| {}), false));
418                }
419            }
420        }
421        // Descend into children in the content space of a placed scroll layer
422        // (screen coords elsewhere). Outside the viewport, content is clipped.
423        let (cx, cy, clipped) = self.child_coords(n, id, x, y);
424        let mut leaf = None;
425        if !clipped {
426            for &child in n.children.iter().rev() {
427                if let Some((cb, positional)) = self.hit_test_node(child, cx, cy, chain) {
428                    // Wrap so LATER invocations are remapped too, not just this
429                    // one. `child_coords` only converts the coordinates used to
430                    // find the hit; the returned callback was previously handed
431                    // straight to the caller, which re-invokes it directly with
432                    // raw SCREEN coords on every subsequent MouseMove during a
433                    // drag (`active_drag` in rosace/src/lib.rs — the callback
434                    // is never re-hit-tested once a drag starts). A positional
435                    // widget (e.g. Slider) declared inside a GPU-composited
436                    // scroll view (D090) expects content-space coordinates on
437                    // every call, so bake the SAME remap into the callback
438                    // itself whenever this node is a transform host — it then
439                    // self-corrects on every future invocation, not just the
440                    // first. Composes for nested transforms: each ancestor
441                    // wraps once more as the recursion unwinds.
442                    let wrapped: HitHandler = match n.transforms.first() {
443                        Some(entry) => {
444                            let vp = entry.viewport_rect;
445                            let z = entry.zoom;
446                            Arc::new(move |sx: f32, sy: f32| {
447                                let off = rosace_state::scroll_offset(id as u64);
448                                cb((sx - vp.origin.x) / z + off[0], (sy - vp.origin.y) / z + off[1]);
449                            })
450                        }
451                        None => cb,
452                    };
453                    leaf = Some((wrapped, positional));
454                    break;
455                }
456            }
457        }
458        if leaf.is_none() {
459            // Only reached when no child matched — same order as before:
460            // positional own-regions first (more specific intent), then
461            // plain ones.
462            for (rect, cb) in n.hits_at.iter().rev() {
463                if contains(rect, x, y) {
464                    leaf = Some((cb.clone(), true));
465                    break;
466                }
467            }
468            if leaf.is_none() {
469                for (rect, cb) in n.hits.iter().rev() {
470                    if contains(rect, x, y) {
471                        let cb = cb.clone();
472                        leaf = Some((Arc::new(move |_, _| cb()), false));
473                        break;
474                    }
475                }
476            }
477        }
478        // Collect THIS node's own nested-scroll region, remapped the same
479        // way a hit callback would be if this node hosts a transform —
480        // unconditional (runs whether or not a leaf was found above, and
481        // regardless of what it was), so the chain always reflects every
482        // scrollable ancestor along the real visual path, not just the
483        // ones "under" wherever the leaf tap/drag happened to resolve.
484        if let Some((_, handler)) = n.nested_scrolls.iter().rev().find(|(r, _)| contains(r, x, y)) {
485            let handler = handler.clone();
486            let wrapped: ScrollHandler = match n.transforms.first() {
487                Some(entry) => {
488                    let z = entry.zoom;
489                    Arc::new(move |dx: f32, dy: f32| handler(dx / z, dy / z))
490                }
491                None => handler,
492            };
493            chain.push(wrapped);
494        }
495        leaf
496    }
497
498    /// Topmost node under the cursor that owns any interactive or hover
499    /// region — drives hover state (buttons, tiles, tooltips).
500    pub fn hover_test(&self, x: f32, y: f32) -> Option<NodeId> {
501        self.hover_test_node(Self::ROOT, x, y)
502    }
503
504    fn hover_test_node(&self, id: NodeId, x: f32, y: f32) -> Option<NodeId> {
505        let n = &self.nodes[id];
506        if n.pointer_mode == 1 {
507            return None;
508        }
509        let (cx, cy, clipped) = self.child_coords(n, id, x, y);
510        if !clipped {
511            for &child in n.children.iter().rev() {
512                if let Some(hit) = self.hover_test_node(child, cx, cy) {
513                    return Some(hit);
514                }
515            }
516        }
517        let owns = n.hits.iter().map(|(r, _)| r)
518            .chain(n.hits_at.iter().map(|(r, _)| r))
519            .chain(n.long_hits.iter().map(|(r, _)| r))
520            .chain(n.hover_regions.iter())
521            .chain(n.nested_scrolls.iter().map(|(r, _)| r))
522            .any(|r| contains(r, x, y));
523        if owns { Some(id) } else { None }
524    }
525
526    /// Topmost long-press callback under the cursor.
527    pub fn long_press_test(&self, x: f32, y: f32) -> Option<Arc<dyn Fn() + Send + Sync>> {
528        self.long_press_node(Self::ROOT, x, y)
529    }
530
531    fn long_press_node(&self, id: NodeId, x: f32, y: f32) -> Option<Arc<dyn Fn() + Send + Sync>> {
532        let n = &self.nodes[id];
533        if n.pointer_mode == 1 {
534            return None;
535        }
536        let (cx, cy, clipped) = self.child_coords(n, id, x, y);
537        if !clipped {
538            for &child in n.children.iter().rev() {
539                if let Some(cb) = self.long_press_node(child, cx, cy) {
540                    return Some(cb);
541                }
542            }
543        }
544        for (rect, cb) in n.long_hits.iter().rev() {
545            if contains(rect, x, y) {
546                return Some(cb.clone());
547            }
548        }
549        None
550    }
551
552    /// Set the hovered node, clearing the previous one. Marks both the old
553    /// and new node dirty so the next walk repaints exactly them (localized
554    /// damage). Returns true when the hover target changed.
555    pub fn set_hover(&mut self, target: Option<NodeId>) -> bool {
556        let current = self.nodes.iter().position(|n| n.hovered);
557        if current == target {
558            return false;
559        }
560        if let Some(old) = current {
561            self.nodes[old].hovered = false;
562            self.nodes[old].paint_dirty = true;
563        }
564        if let Some(new) = target {
565            self.nodes[new].hovered = true;
566            self.nodes[new].paint_dirty = true;
567        }
568        true
569    }
570
571    /// Set the pressed node, clearing the previous one — same shape as
572    /// [`Self::set_hover`], driven by MouseDown/MouseUp instead of
573    /// MouseMove. Returns true when the pressed target changed.
574    pub fn set_pressed(&mut self, target: Option<NodeId>) -> bool {
575        let current = self.nodes.iter().position(|n| n.pressed);
576        if current == target {
577            return false;
578        }
579        if let Some(old) = current {
580            self.nodes[old].pressed = false;
581            self.nodes[old].paint_dirty = true;
582        }
583        if let Some(new) = target {
584            self.nodes[new].pressed = true;
585            self.nodes[new].paint_dirty = true;
586        }
587        true
588    }
589
590    /// Axis-aware scroll routing: among the viewports under the cursor
591    /// (innermost first), pick the first that handles the DOMINANT axis of
592    /// the delta; fall back to the innermost that handles the other axis.
593    /// A horizontal carousel no longer intercepts a vertical page scroll.
594    pub fn scroll_test(&self, x: f32, y: f32, dx: f32, dy: f32)
595        -> Option<HitHandler>
596    {
597        let mut candidates: Vec<(ScrollAxes, HitHandler)> = Vec::new();
598        self.scroll_candidates(Self::ROOT, x, y, &mut candidates);
599        select_scroll_handler(&candidates, dx, dy)
600    }
601
602    fn scroll_candidates(
603        &self,
604        id: NodeId,
605        x: f32,
606        y: f32,
607        out: &mut Vec<(ScrollAxes, HitHandler)>,
608    ) {
609        let n = &self.nodes[id];
610        // Descend in the CHILD's coordinate space when this node hosts a
611        // transform (D090/D092) — bug found live: a scrollable widget
612        // (InteractiveViewer) nested inside another scroll view (a normal
613        // scrolling page) registers its own scroll target in that OUTER
614        // view's content-local space, not real screen space; recursing with
615        // the raw, unremapped (x, y) meant its rect could never match a real
616        // cursor position, so scroll silently fell through to the outer
617        // page every time. `hit_test_node` already gets this right via
618        // `child_coords` for clicks — mirror it here for wheel/trackpad too.
619        let (cx, cy, clipped) = self.child_coords(n, id, x, y);
620        if !clipped {
621            // Children first (topmost/innermost priority), later siblings first.
622            for &child in n.children.iter().rev() {
623                self.scroll_candidates(child, cx, cy, out);
624            }
625        }
626        for (rect, axes, cb) in n.scrolls.iter().rev() {
627            if contains(rect, x, y) {
628                out.push((*axes, cb.clone()));
629            }
630        }
631    }
632
633    /// Innermost registered zoom region under `(x, y)` (trackpad pinch,
634    /// `InteractiveViewer`) — same innermost-first, later-sibling-first
635    /// priority as `scroll_test`, but with no axis-selection step (a pinch
636    /// gesture has no "axis", just one delta).
637    pub fn zoom_test(&self, x: f32, y: f32) -> Option<Arc<dyn Fn(f32) + Send + Sync>> {
638        self.zoom_candidate(Self::ROOT, x, y)
639    }
640
641    fn zoom_candidate(&self, id: NodeId, x: f32, y: f32) -> Option<Arc<dyn Fn(f32) + Send + Sync>> {
642        let n = &self.nodes[id];
643        // Same nested-transform remap as `scroll_candidates` — see its
644        // comment for the bug this fixes.
645        let (cx, cy, clipped) = self.child_coords(n, id, x, y);
646        if !clipped {
647            for &child in n.children.iter().rev() {
648                if let Some(cb) = self.zoom_candidate(child, cx, cy) {
649                    return Some(cb);
650                }
651            }
652        }
653        for (rect, cb) in n.zooms.iter().rev() {
654            if contains(rect, x, y) {
655                return Some(cb.clone());
656            }
657        }
658        None
659    }
660
661    /// All hit regions in tree (paint) order — used by the overlay pass to
662    /// flatten a per-entry subtree into a dispatch list.
663    pub fn collect_hits(&self) -> Vec<HitRegion> {
664        let mut out = Vec::new();
665        self.collect_hits_node(Self::ROOT, &mut out);
666        out
667    }
668
669    fn collect_hits_node(&self, id: NodeId, out: &mut Vec<HitRegion>) {
670        let n = &self.nodes[id];
671        out.extend(n.hits.iter().cloned());
672        for &child in &n.children {
673            self.collect_hits_node(child, out);
674        }
675    }
676
677    /// All scroll regions in tree (paint) order.
678    pub fn collect_scrolls(&self) -> Vec<ScrollRegion> {
679        let mut out = Vec::new();
680        self.collect_scrolls_node(Self::ROOT, &mut out);
681        out
682    }
683
684    fn collect_scrolls_node(&self, id: NodeId, out: &mut Vec<ScrollRegion>) {
685        let n = &self.nodes[id];
686        out.extend(n.scrolls.iter().cloned());
687        for &child in &n.children {
688            self.collect_scrolls_node(child, out);
689        }
690    }
691
692    /// All focus nodes in tree (paint) order — feeds the Tab cycle each frame,
693    /// including cache-hit frames where no widget was repainted.
694    pub fn collect_focus(&self) -> Vec<rosace_a11y::FocusNode> {
695        let mut out = Vec::new();
696        self.collect_focus_node(Self::ROOT, &mut out);
697        out
698    }
699
700    fn collect_focus_node(&self, id: NodeId, out: &mut Vec<rosace_a11y::FocusNode>) {
701        let n = &self.nodes[id];
702        out.extend(n.focus.iter().cloned());
703        for &child in &n.children {
704            self.collect_focus_node(child, out);
705        }
706    }
707
708    /// The render-tree node that declared the [`rosace_a11y::FocusNode`]
709    /// with id `focus_id` (D112/Phase 28 Step 1) — bridges
710    /// `FocusManager::focused` (a `FocusNode`'s own global id) back to a
711    /// `NodeId`, so the engine's key dispatch can find and mutate that
712    /// node's persistent `text_edit`/`editable` state.
713    pub fn focus_owner(&self, focus_id: u64) -> Option<NodeId> {
714        self.nodes.iter().position(|n| n.focus.iter().any(|f| f.id() == focus_id))
715    }
716
717    /// Topmost editable node whose declared rect contains `(x, y)` — used
718    /// by the engine to focus (and, Step 1: place the caret at the end
719    /// of) an editable widget on click (D112/Phase 28). Same z-order
720    /// traversal as [`Self::hover_test`]; editable rects live in
721    /// `TreeNode::editable`, declared by [`super::PaintCtx::register_editable`].
722    pub fn editable_test(&self, x: f32, y: f32) -> Option<NodeId> {
723        self.editable_test_node(Self::ROOT, x, y)
724    }
725
726    fn editable_test_node(&self, id: NodeId, x: f32, y: f32) -> Option<NodeId> {
727        let n = &self.nodes[id];
728        if n.pointer_mode == 1 {
729            return None;
730        }
731        let (cx, cy, clipped) = self.child_coords(n, id, x, y);
732        if !clipped {
733            for &child in n.children.iter().rev() {
734                if let Some(hit) = self.editable_test_node(child, cx, cy) {
735                    return Some(hit);
736                }
737            }
738        }
739        if let Some(e) = &n.editable {
740            if contains(&e.rect, x, y) {
741                return Some(id);
742            }
743        }
744        None
745    }
746
747    /// Derive the accessibility tree (D099): semantics entries in paint
748    /// order, nested by render-tree structure. Branches with no semantic
749    /// content anywhere below them are pruned.
750    pub fn collect_semantics(&self) -> rosace_core::SemanticNode {
751        let mut root = rosace_core::SemanticNode::new();
752        self.collect_semantics_node(Self::ROOT, &mut root);
753        root
754    }
755
756    fn collect_semantics_node(&self, id: NodeId, parent: &mut rosace_core::SemanticNode) {
757        let n = &self.nodes[id];
758        for s in &n.semantics {
759            let mut sn = rosace_core::SemanticNode::new().role(s.role.clone());
760            if let Some(l) = &s.label { sn = sn.label(l.clone()); }
761            // `value`/`heading_level`/`href` were silently dropped here before
762            // D107/Phase 25 — a real gap for a `TextInput`'s current text, a
763            // `Slider`/`ProgressBar`'s value, and (once widgets start setting
764            // them) a heading's level or a link's target, all of which matter
765            // for a faithful HTML/SEO mapping, not just for assistive tech.
766            if let Some(v) = &s.value { sn = sn.value(v.clone()); }
767            if let Some(lvl) = s.heading_level { sn = sn.heading_level(lvl); }
768            if let Some(h) = &s.href { sn = sn.href(h.clone()); }
769            parent.children.push(sn);
770        }
771        // Children nest under THIS node's last semantic entry when it declared
772        // one (a Button's inner Text belongs to the Button); nodes with no
773        // semantics of their own flatten their children into the parent.
774        let target: &mut rosace_core::SemanticNode = if n.semantics.is_empty() {
775            parent
776        } else {
777            let last = parent.children.len() - 1;
778            &mut parent.children[last]
779        };
780        for &child in &n.children {
781            self.collect_semantics_node(child, target);
782        }
783    }
784
785    /// All overlay entries in tree order (insertion order = z-order, D058).
786    /// Map a point expressed in `target`'s CONTENT space to window/screen
787    /// space, applying the inverse of every transform-host remap on the
788    /// path from the root (each is a pure translation: + viewport origin
789    /// − scroll offset). Phase 32 bug fix (user-reported): an overlay
790    /// anchored by a widget inside a GPU scroll layer (e.g. a Tooltip's
791    /// `Absolute` position) carried content coords into the window-space
792    /// overlay pass and rendered far from its anchor.
793    pub fn content_to_screen(&self, target: NodeId, p: rosace_core::types::Point) -> rosace_core::types::Point {
794        let mut path = Vec::new();
795        if !self.path_to(Self::ROOT, target, &mut path) {
796            return p;
797        }
798        let mut out = p;
799        for &id in &path {
800            if id == target {
801                continue; // a host remaps its CHILDREN, not itself
802            }
803            let n = &self.nodes[id];
804            if let Some(entry) = n.transforms.first() {
805                let off = rosace_state::scroll_offset(id as u64);
806                // Inverse of child_coords' `(screen - vp.origin)/zoom + offset`.
807                out.x = (out.x - off[0]) * entry.zoom + entry.viewport_rect.origin.x;
808                out.y = (out.y - off[1]) * entry.zoom + entry.viewport_rect.origin.y;
809            }
810        }
811        out
812    }
813
814    fn path_to(&self, cur: NodeId, target: NodeId, path: &mut Vec<NodeId>) -> bool {
815        path.push(cur);
816        if cur == target {
817            return true;
818        }
819        for &child in &self.nodes[cur].children {
820            if self.path_to(child, target, path) {
821                return true;
822            }
823        }
824        path.pop();
825        false
826    }
827
828    pub fn overlay_ids(&self) -> Vec<(NodeId, usize)> {
829        let mut out = Vec::new();
830        self.overlay_ids_node(Self::ROOT, &mut out);
831        out
832    }
833
834    fn overlay_ids_node(&self, id: NodeId, out: &mut Vec<(NodeId, usize)>) {
835        let n = &self.nodes[id];
836        for i in 0..n.overlays.len() {
837            out.push((id, i));
838        }
839        for &child in &n.children {
840            self.overlay_ids_node(child, out);
841        }
842    }
843
844    /// All transform-layer entries in tree order.
845    pub fn transform_ids(&self) -> Vec<(NodeId, usize)> {
846        let mut out = Vec::new();
847        self.transform_ids_node(Self::ROOT, &mut out);
848        out
849    }
850
851    fn transform_ids_node(&self, id: NodeId, out: &mut Vec<(NodeId, usize)>) {
852        let n = &self.nodes[id];
853        for i in 0..n.transforms.len() {
854            out.push((id, i));
855        }
856        for &child in &n.children {
857            self.transform_ids_node(child, out);
858        }
859    }
860
861    /// Read-only snapshot of the live tree (D123/O2) — plain data, safe to
862    /// hand to a DevTools overlay: no callbacks, no `Arc<dyn Fn>`, nothing
863    /// that could be invoked or mutated through it. "Live" means reachable
864    /// from the root through `children` as of the last `finalize()` — an
865    /// arena slot orphaned by a removed widget is not included, even though
866    /// its `TreeNode` still physically exists until the slot is reused.
867    ///
868    /// Additive and non-invasive: reads fields every node already carries,
869    /// touches nothing about how painting/hit-testing/layout work.
870    pub fn inspect(&self) -> Vec<InspectNode> {
871        let mut out = Vec::new();
872        self.inspect_node(Self::ROOT, None, &mut out);
873        out
874    }
875
876    fn inspect_node(&self, id: NodeId, parent: Option<NodeId>, out: &mut Vec<InspectNode>) {
877        let n = &self.nodes[id];
878        out.push(InspectNode {
879            id,
880            parent,
881            children: n.children.clone(),
882            tag: n.tag,
883            rect: n.cached_rect,
884            size: n.cached_size,
885            constraints: n.last_constraints,
886            semantics: n.semantics.iter()
887                .map(|s| (s.role.clone(), s.label.clone()))
888                .collect(),
889            hit_count: n.hits.len() + n.hits_at.len() + n.long_hits.len(),
890            scroll_count: n.scrolls.len(),
891            overlay_count: n.overlays.len(),
892            has_editable: n.editable.is_some(),
893            hovered: n.hovered,
894            pressed: n.pressed,
895        });
896        for &child in &n.children {
897            self.inspect_node(child, Some(id), out);
898        }
899    }
900
901    /// The node whose `rect` contains `(x, y)` and is deepest (most
902    /// specific) in the tree — the element-picker hit target (D123/O2).
903    /// Unlike [`Self::hover_test`]/[`Self::hit_test`], this considers EVERY
904    /// node's paint rect, not just ones that declared an interactive
905    /// region — a plain `Container`/`Text` is pickable too. Ties (same
906    /// depth) go to the one painted later (topmost in z-order), mirroring
907    /// every other hit-order convention in this file.
908    pub fn pick(&self, x: f32, y: f32) -> Option<NodeId> {
909        let snapshot = self.inspect();
910        let by_id: std::collections::HashMap<NodeId, &InspectNode> =
911            snapshot.iter().map(|n| (n.id, n)).collect();
912
913        fn depth(by_id: &std::collections::HashMap<NodeId, &InspectNode>, mut id: NodeId) -> u32 {
914            let mut d = 0;
915            while let Some(p) = by_id.get(&id).and_then(|n| n.parent) {
916                d += 1;
917                id = p;
918            }
919            d
920        }
921
922        let mut best: Option<(NodeId, u32)> = None;
923        for n in &snapshot {
924            let Some(r) = n.rect else { continue; };
925            if !contains(&r, x, y) { continue; }
926            let d = depth(&by_id, n.id);
927            match best {
928                Some((_, bd)) if bd > d => {}
929                Some((bid, bd)) if bd == d && bid > n.id => {}
930                _ => best = Some((n.id, d)),
931            }
932        }
933        best.map(|(id, _)| id)
934    }
935}
936
937/// One node in an [`RenderTree::inspect`] snapshot — plain data only.
938#[derive(Clone, Debug)]
939pub struct InspectNode {
940    pub id: NodeId,
941    pub parent: Option<NodeId>,
942    pub children: Vec<NodeId>,
943    /// Widget type name (`std::any::type_name`-derived tag already tracked
944    /// per node for the picture cache).
945    pub tag: &'static str,
946    pub rect: Option<Rect>,
947    pub size: Option<Size>,
948    pub constraints: Option<Constraints>,
949    /// This node's own declared semantics (role, label) — usually 0 or 1
950    /// entries; a few widgets (e.g. a labeled group) declare more than one.
951    pub semantics: Vec<(rosace_core::Role, Option<String>)>,
952    pub hit_count: usize,
953    pub scroll_count: usize,
954    pub overlay_count: usize,
955    pub has_editable: bool,
956    pub hovered: bool,
957    pub pressed: bool,
958}
959
960impl Default for RenderTree {
961    fn default() -> Self { Self::new() }
962}
963
964/// Shared axis-preference selection (also used for overlay scroll routes):
965/// first candidate handling the dominant delta axis, else first handling
966/// the other axis.
967pub fn select_scroll_handler(
968    candidates: &[(ScrollAxes, HitHandler)],
969    dx: f32,
970    dy: f32,
971) -> Option<Arc<dyn Fn(f32, f32) + Send + Sync>> {
972    let dominant_is_x = dx.abs() > dy.abs();
973    let handles_dominant = |a: &ScrollAxes| if dominant_is_x { a.x } else { a.y };
974    let handles_other = |a: &ScrollAxes| if dominant_is_x { a.y } else { a.x };
975    candidates.iter().find(|(a, _)| handles_dominant(a))
976        .or_else(|| candidates.iter().find(|(a, _)| handles_other(a)))
977        .map(|(_, cb)| cb.clone())
978}
979
980#[inline]
981fn contains(r: &Rect, x: f32, y: f32) -> bool {
982    x >= r.origin.x
983        && x <= r.origin.x + r.size.width
984        && y >= r.origin.y
985        && y <= r.origin.y + r.size.height
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use rosace_core::types::{Point, Size};
992
993    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
994        Rect { origin: Point { x, y }, size: Size { width: w, height: h } }
995    }
996
997    #[test]
998    fn hits_persist_on_unpainted_subtree() {
999        let mut t = RenderTree::new();
1000        t.start_frame();
1001        let a = t.slot(RenderTree::ROOT, true);
1002        t.node_mut(a).hits.push((rect(0.0, 0.0, 10.0, 10.0), Arc::new(|| {})));
1003        t.finalize();
1004
1005        // Next frame: root repaints but the child slot is kept (cache hit).
1006        t.start_frame();
1007        let a2 = t.slot(RenderTree::ROOT, false);
1008        t.finalize();
1009
1010        assert_eq!(a, a2);
1011        assert!(t.hit_test(5.0, 5.0).0.is_some(), "hit must survive the clean frame");
1012    }
1013
1014    #[test]
1015    fn set_pressed_clears_the_previous_target_and_reports_whether_it_changed() {
1016        let mut t = RenderTree::new();
1017        t.start_frame();
1018        let a = t.slot(RenderTree::ROOT, true);
1019        let b = t.slot(RenderTree::ROOT, true);
1020        t.finalize();
1021
1022        assert!(t.set_pressed(Some(a)), "unset -> Some(a) is a change");
1023        assert!(t.node(a).pressed);
1024        assert!(!t.node(b).pressed);
1025
1026        assert!(!t.set_pressed(Some(a)), "Some(a) -> Some(a) is not a change");
1027
1028        assert!(t.set_pressed(Some(b)), "Some(a) -> Some(b) is a change");
1029        assert!(!t.node(a).pressed, "old target must be cleared");
1030        assert!(t.node(b).pressed);
1031
1032        assert!(t.set_pressed(None), "Some(b) -> None is a change");
1033        assert!(!t.node(b).pressed);
1034    }
1035
1036    #[test]
1037    fn repaint_clears_declared_data() {
1038        let mut t = RenderTree::new();
1039        t.start_frame();
1040        let a = t.slot(RenderTree::ROOT, true);
1041        t.node_mut(a).hits.push((rect(0.0, 0.0, 10.0, 10.0), Arc::new(|| {})));
1042        t.finalize();
1043
1044        t.start_frame();
1045        let _a = t.slot(RenderTree::ROOT, true); // fresh repaint, declares nothing
1046        t.finalize();
1047
1048        assert!(t.hit_test(5.0, 5.0).0.is_none(), "repaint must clear stale hits");
1049    }
1050
1051    #[test]
1052    fn later_siblings_win_hit_test() {
1053        let mut t = RenderTree::new();
1054        t.start_frame();
1055        let first = t.slot(RenderTree::ROOT, true);
1056        let hit_first = Arc::new(std::sync::atomic::AtomicBool::new(false));
1057        let hf = hit_first.clone();
1058        t.node_mut(first).hits.push((rect(0.0, 0.0, 10.0, 10.0), Arc::new(move || {
1059            hf.store(true, std::sync::atomic::Ordering::SeqCst);
1060        })));
1061        let second = t.slot(RenderTree::ROOT, true);
1062        t.node_mut(second).hits.push((rect(0.0, 0.0, 10.0, 10.0), Arc::new(|| {})));
1063        t.finalize();
1064
1065        // Overlapping rects: the later sibling (painted on top) must win.
1066        let (cb, _) = t.hit_test(5.0, 5.0).0.unwrap();
1067        cb(0.0, 0.0);
1068        assert!(!hit_first.load(std::sync::atomic::Ordering::SeqCst));
1069    }
1070
1071    #[test]
1072    fn content_to_screen_inverts_the_scroll_layer_remap() {
1073        // Same fixture shape as hit_test_maps_through_scroll_layer_offset:
1074        // viewport at (50,50), scrolled 200 down. A content point at
1075        // (0, 240) must map to screen (50, 90) — the exact inverse of the
1076        // hit-test's screen→content mapping (Phase 32 tooltip-position fix).
1077        let mut t = RenderTree::new();
1078        t.start_frame();
1079        let tl = t.slot(RenderTree::ROOT, true);
1080        t.node_mut(tl).transforms.push(TransformLayerEntry {
1081            picture: rosace_render::PictureRecorder::new().finish(),
1082            child_size: Size { width: 100.0, height: 1000.0 },
1083            viewport_rect: rect(50.0, 50.0, 100.0, 100.0),
1084            zoom: 1.0,
1085            scroll_x: 0.0,
1086            scroll_y: 0.0,
1087        });
1088        let child = t.slot(tl, true);
1089        t.finalize();
1090        rosace_state::set_scroll_offset(tl as u64, [0.0, 200.0]);
1091
1092        let p = t.content_to_screen(child, rosace_core::types::Point { x: 0.0, y: 240.0 });
1093        assert_eq!((p.x, p.y), (50.0, 90.0), "content→screen must invert child_coords");
1094
1095        // A node OUTSIDE any layer maps through unchanged.
1096        let plain = t.content_to_screen(tl, rosace_core::types::Point { x: 7.0, y: 9.0 });
1097        assert_eq!((plain.x, plain.y), (7.0, 9.0));
1098
1099        rosace_state::clear_scroll_offset(tl as u64);
1100    }
1101
1102    #[test]
1103    fn hit_test_maps_through_scroll_layer_offset() {
1104        use std::sync::atomic::{AtomicBool, Ordering};
1105        // A transform node with a 100×100 viewport at (50,50), scrolled 200px
1106        // down. Its child declares a hit at content-local (0,300)-(100,340).
1107        let mut t = RenderTree::new();
1108        t.start_frame();
1109        let tl = t.slot(RenderTree::ROOT, true);
1110        t.node_mut(tl).transforms.push(TransformLayerEntry {
1111            picture: rosace_render::PictureRecorder::new().finish(),
1112            child_size: Size { width: 100.0, height: 1000.0 },
1113            viewport_rect: rect(50.0, 50.0, 100.0, 100.0),
1114            zoom: 1.0,
1115            scroll_x: 0.0,
1116            scroll_y: 0.0,
1117        });
1118        let child = t.slot(tl, true);
1119        let hit = Arc::new(AtomicBool::new(false));
1120        let h = hit.clone();
1121        // Content-local region visible at scroll 200 (content y 200..300).
1122        t.node_mut(child).hits.push((rect(0.0, 220.0, 100.0, 40.0), Arc::new(move || {
1123            h.store(true, Ordering::SeqCst);
1124        })));
1125        t.finalize();
1126
1127        // Live offset lives in the channel keyed by the transform node id.
1128        rosace_state::set_scroll_offset(tl as u64, [0.0, 200.0]);
1129
1130        // Screen (75,90): inside the viewport (50..150); content y = 90-50+200
1131        // = 240, which lands in the child's [220,260) region → hits.
1132        let (cb, _) = t.hit_test(75.0, 90.0).0.expect("content region must be hit through the offset");
1133        cb(0.0, 0.0);
1134        assert!(hit.load(Ordering::SeqCst), "click mapped into scrolled content");
1135
1136        // Screen (75, 40): ABOVE the viewport → clipped, no hit.
1137        assert!(t.hit_test(75.0, 40.0).0.is_none(), "clicks outside the viewport are clipped");
1138
1139        rosace_state::clear_scroll_offset(tl as u64);
1140    }
1141
1142    #[test]
1143    fn positional_hit_through_transform_remaps_every_invocation() {
1144        // A positional widget (e.g. a Slider knob) declared inside a
1145        // GPU-composited scroll view (D090). The app dispatch loop invokes
1146        // the returned callback once at press time AND again on every
1147        // subsequent MouseMove for the rest of the drag, WITHOUT re-running
1148        // hit_test (see the `active_drag` mechanism in rosace/src/lib.rs) —
1149        // so the callback itself must remap raw screen coords through the
1150        // transform on every call, not just the one made at hit-test time.
1151        let mut t = RenderTree::new();
1152        t.start_frame();
1153        let tl = t.slot(RenderTree::ROOT, true);
1154        t.node_mut(tl).transforms.push(TransformLayerEntry {
1155            picture: rosace_render::PictureRecorder::new().finish(),
1156            child_size: Size { width: 100.0, height: 1000.0 },
1157            viewport_rect: rect(50.0, 50.0, 100.0, 100.0),
1158            zoom: 1.0,
1159            scroll_x: 0.0,
1160            scroll_y: 0.0,
1161        });
1162        let child = t.slot(tl, true);
1163        let received = Arc::new(std::sync::Mutex::new(Vec::new()));
1164        let r = received.clone();
1165        t.node_mut(child).hits_at.push((rect(0.0, 220.0, 100.0, 40.0), Arc::new(move |cx, cy| {
1166            r.lock().unwrap().push((cx, cy));
1167        })));
1168        t.finalize();
1169
1170        rosace_state::set_scroll_offset(tl as u64, [0.0, 200.0]);
1171
1172        // Screen (75,90): content = (75-50+0, 90-50+200) = (25, 240) → inside [220,260).
1173        let (cb, positional) = t.hit_test(75.0, 90.0).0.expect("must hit the positional region");
1174        assert!(positional, "hits_at region must report positional=true");
1175        cb(75.0, 90.0); // initial press — dispatch calls back with the same raw coords used to find it
1176
1177        // Simulated drag continuation: fresh raw screen coords, same callback,
1178        // no re-hit-test. Before this fix these would leak straight through
1179        // unmapped.
1180        cb(80.0, 95.0); // content = (80-50+0, 95-50+200) = (30, 245)
1181
1182        let got = received.lock().unwrap();
1183        assert_eq!(
1184            *got,
1185            vec![(25.0, 240.0), (30.0, 245.0)],
1186            "every invocation must be remapped through the transform, not just the first"
1187        );
1188
1189        rosace_state::clear_scroll_offset(tl as u64);
1190    }
1191
1192    #[test]
1193    fn semantics_tree_nests_under_declaring_node() {
1194        use rosace_core::Role;
1195        let mut t = RenderTree::new();
1196        t.start_frame();
1197        let button = t.slot(RenderTree::ROOT, true);
1198        t.node_mut(button).semantics.push(
1199            crate::tree::Semantics::new(Role::Button).label("Save"),
1200        );
1201        // Button's inner text node — must nest under the Button.
1202        let label = t.slot(button, true);
1203        t.node_mut(label).semantics.push(
1204            crate::tree::Semantics::new(Role::Text).label("Save"),
1205        );
1206        t.finalize();
1207
1208        let sem = t.collect_semantics();
1209        assert_eq!(sem.children.len(), 1, "one top-level semantic node");
1210        assert_eq!(sem.children[0].role, Role::Button);
1211        assert_eq!(sem.children[0].children.len(), 1);
1212        assert_eq!(sem.children[0].children[0].role, Role::Text);
1213    }
1214
1215    #[test]
1216    fn collect_semantics_carries_value_heading_level_and_href() {
1217        // D107/Phase 25: these three were silently dropped by
1218        // collect_semantics_node before this fix — real gap for HTML/SEO
1219        // mapping (a TextInput's current text, a heading's level, a link's
1220        // target all matter for a faithful export, not just role/label).
1221        use rosace_core::Role;
1222        let mut t = RenderTree::new();
1223        t.start_frame();
1224        let input = t.slot(RenderTree::ROOT, true);
1225        t.node_mut(input).semantics.push(
1226            crate::tree::Semantics::new(Role::TextInput).label("Name").value("Ada"),
1227        );
1228        let heading = t.slot(RenderTree::ROOT, true);
1229        t.node_mut(heading).semantics.push(
1230            crate::tree::Semantics::new(Role::Heading).label("Section").heading_level(2),
1231        );
1232        let link = t.slot(RenderTree::ROOT, true);
1233        t.node_mut(link).semantics.push(
1234            crate::tree::Semantics::new(Role::Link).label("Docs").href("https://example.com"),
1235        );
1236        t.finalize();
1237
1238        let sem = t.collect_semantics();
1239        assert_eq!(sem.children[0].value.as_deref(), Some("Ada"));
1240        assert_eq!(sem.children[1].heading_level, Some(2));
1241        assert_eq!(sem.children[2].href.as_deref(), Some("https://example.com"));
1242    }
1243
1244    #[test]
1245    fn finalize_drops_removed_children() {
1246        let mut t = RenderTree::new();
1247        t.start_frame();
1248        let a = t.slot(RenderTree::ROOT, true);
1249        t.node_mut(a).hits.push((rect(0.0, 0.0, 10.0, 10.0), Arc::new(|| {})));
1250        let b = t.slot(RenderTree::ROOT, true);
1251        t.node_mut(b).hits.push((rect(20.0, 0.0, 10.0, 10.0), Arc::new(|| {})));
1252        t.finalize();
1253
1254        // Next frame the root only paints one child.
1255        t.start_frame();
1256        let _a = t.slot(RenderTree::ROOT, true);
1257        t.finalize();
1258
1259        assert!(t.hit_test(25.0, 5.0).0.is_none(), "removed child left a ghost hit");
1260    }
1261
1262    #[test]
1263    fn inspect_reports_parent_child_rect_and_tag() {
1264        let mut t = RenderTree::new();
1265        t.start_frame();
1266        let a = t.slot(RenderTree::ROOT, true);
1267        t.node_mut(a).tag = "Container";
1268        t.node_mut(a).cached_rect = Some(rect(0.0, 0.0, 100.0, 50.0));
1269        t.node_mut(a).cached_size = Some(Size { width: 100.0, height: 50.0 });
1270        t.finalize();
1271
1272        let snap = t.inspect();
1273        assert_eq!(snap.len(), 2, "root + one child");
1274        let root = snap.iter().find(|n| n.id == RenderTree::ROOT).unwrap();
1275        assert_eq!(root.parent, None);
1276        assert_eq!(root.children, vec![a]);
1277
1278        let child = snap.iter().find(|n| n.id == a).unwrap();
1279        assert_eq!(child.parent, Some(RenderTree::ROOT));
1280        assert_eq!(child.tag, "Container");
1281        assert_eq!(child.rect.map(|r| (r.origin.x, r.size.width)), Some((0.0, 100.0)));
1282        assert_eq!(child.size, Some(Size { width: 100.0, height: 50.0 }));
1283    }
1284
1285    #[test]
1286    fn inspect_omits_nodes_dropped_by_finalize() {
1287        let mut t = RenderTree::new();
1288        t.start_frame();
1289        let a = t.slot(RenderTree::ROOT, true);
1290        let _b = t.slot(RenderTree::ROOT, true);
1291        t.finalize();
1292        assert_eq!(t.inspect().len(), 3, "root + a + b");
1293
1294        // Next frame only paints `a` — `b`'s slot is dropped by finalize.
1295        t.start_frame();
1296        let _a2 = t.slot(RenderTree::ROOT, true);
1297        t.finalize();
1298
1299        let snap = t.inspect();
1300        assert_eq!(snap.len(), 2, "root + a only — the orphaned slot must not appear");
1301        assert!(snap.iter().any(|n| n.id == a));
1302    }
1303
1304    #[test]
1305    fn inspect_surfaces_semantics_and_interaction_flags() {
1306        use rosace_core::Role;
1307        let mut t = RenderTree::new();
1308        t.start_frame();
1309        let btn = t.slot(RenderTree::ROOT, true);
1310        t.node_mut(btn).semantics.push(super::super::Semantics::new(Role::Button).label("Save"));
1311        t.node_mut(btn).hits.push((rect(0.0, 0.0, 10.0, 10.0), Arc::new(|| {})));
1312        t.node_mut(btn).hovered = true;
1313        t.finalize();
1314
1315        let snap = t.inspect();
1316        let node = snap.iter().find(|n| n.id == btn).unwrap();
1317        assert_eq!(node.semantics, vec![(Role::Button, Some("Save".to_string()))]);
1318        assert_eq!(node.hit_count, 1);
1319        assert!(node.hovered);
1320        assert!(!node.pressed);
1321    }
1322
1323    #[test]
1324    fn pick_finds_the_deepest_node_containing_the_point() {
1325        let mut t = RenderTree::new();
1326        t.start_frame();
1327        t.node_mut(RenderTree::ROOT).cached_rect = Some(rect(0.0, 0.0, 200.0, 200.0));
1328        let outer = t.slot(RenderTree::ROOT, true);
1329        t.node_mut(outer).cached_rect = Some(rect(0.0, 0.0, 100.0, 100.0));
1330        let inner = t.slot(outer, true);
1331        t.node_mut(inner).cached_rect = Some(rect(10.0, 10.0, 30.0, 30.0));
1332        t.finalize();
1333
1334        // Inside the inner rect: must pick the deepest (most specific) node.
1335        assert_eq!(t.pick(15.0, 15.0), Some(inner));
1336        // Inside outer but outside inner: picks outer.
1337        assert_eq!(t.pick(50.0, 50.0), Some(outer));
1338        // Inside root but outside everything else: picks root.
1339        assert_eq!(t.pick(150.0, 150.0), Some(RenderTree::ROOT));
1340        // Outside all rects: nothing.
1341        assert_eq!(t.pick(-5.0, -5.0), None);
1342    }
1343}