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}